-
# frozen_string_literal: true
-
-
module ApplicationCable
-
class Channel < ActionCable::Channel::Base
-
end
-
end
-
# frozen_string_literal: true
-
-
module ApplicationCable
-
class Connection < ActionCable::Connection::Base
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Admin
-
class DepartmentsController < BaseController
-
before_action :require_admin_or_hr
-
-
# GET /api/v1/admin/departments
-
def index
-
# Get unique departments from employees
-
employee_departments = ::Hr::Employee.where(organization_id: current_organization.id)
-
.distinct(:department)
-
.compact
-
-
# Get configured departments from organization settings
-
configured_departments = current_organization.settings&.dig("departments") || []
-
-
# Merge both lists (configured + any that exist in employees)
-
all_departments = (configured_departments + employee_departments).uniq.sort
-
-
# Build department stats
-
departments_with_stats = all_departments.map do |dept|
-
employee_count = ::Hr::Employee.where(organization_id: current_organization.id, department: dept).count
-
{
-
id: dept.parameterize,
-
name: dept,
-
code: dept.parameterize.upcase.gsub("-", "_"),
-
employee_count: employee_count,
-
active: configured_departments.include?(dept) || employee_count > 0,
-
is_configured: configured_departments.include?(dept)
-
}
-
end
-
-
render json: {
-
data: departments_with_stats,
-
meta: {
-
total: departments_with_stats.count,
-
total_employees: ::Hr::Employee.where(organization_id: current_organization.id).count
-
}
-
}
-
end
-
-
# POST /api/v1/admin/departments
-
def create
-
name = params[:department][:name]&.strip
-
-
if name.blank?
-
return render json: { error: "El nombre del departamento es requerido" }, status: :unprocessable_entity
-
end
-
-
# Get current departments
-
departments = current_organization.settings&.dig("departments") || []
-
-
if departments.include?(name)
-
return render json: { error: "El departamento ya existe" }, status: :unprocessable_entity
-
end
-
-
# Add new department
-
departments << name
-
current_organization.settings ||= {}
-
current_organization.settings["departments"] = departments.sort
-
current_organization.save!
-
-
render json: {
-
data: {
-
id: name.parameterize,
-
name: name,
-
code: name.parameterize.upcase.gsub("-", "_"),
-
employee_count: 0,
-
active: true,
-
is_configured: true
-
},
-
message: "Departamento creado correctamente"
-
}, status: :created
-
end
-
-
# PATCH /api/v1/admin/departments/:id
-
def update
-
old_name = params[:id].gsub("-", " ").titleize
-
new_name = params[:department][:name]&.strip
-
-
if new_name.blank?
-
return render json: { error: "El nombre del departamento es requerido" }, status: :unprocessable_entity
-
end
-
-
departments = current_organization.settings&.dig("departments") || []
-
-
# Find the original department (case-insensitive)
-
original = departments.find { |d| d.parameterize == params[:id] }
-
-
unless original
-
return render json: { error: "Departamento no encontrado" }, status: :not_found
-
end
-
-
# Update in settings
-
departments = departments.map { |d| d == original ? new_name : d }
-
current_organization.settings["departments"] = departments.sort
-
current_organization.save!
-
-
# Update employees with this department
-
::Hr::Employee.where(organization_id: current_organization.id, department: original)
-
.update_all(department: new_name)
-
-
employee_count = ::Hr::Employee.where(organization_id: current_organization.id, department: new_name).count
-
-
render json: {
-
data: {
-
id: new_name.parameterize,
-
name: new_name,
-
code: new_name.parameterize.upcase.gsub("-", "_"),
-
employee_count: employee_count,
-
active: true,
-
is_configured: true
-
},
-
message: "Departamento actualizado correctamente"
-
}
-
end
-
-
# DELETE /api/v1/admin/departments/:id
-
def destroy
-
departments = current_organization.settings&.dig("departments") || []
-
-
# Find the department
-
department = departments.find { |d| d.parameterize == params[:id] }
-
-
unless department
-
return render json: { error: "Departamento no encontrado" }, status: :not_found
-
end
-
-
# Check if has employees
-
employee_count = ::Hr::Employee.where(organization_id: current_organization.id, department: department).count
-
-
if employee_count > 0
-
return render json: {
-
error: "No se puede eliminar un departamento con empleados asignados",
-
employee_count: employee_count
-
}, status: :unprocessable_entity
-
end
-
-
# Remove from settings
-
departments.delete(department)
-
current_organization.settings["departments"] = departments
-
current_organization.save!
-
-
render json: { message: "Departamento eliminado correctamente" }
-
end
-
-
# POST /api/v1/admin/departments/:id/toggle_active
-
def toggle_active
-
# For now, just return success - departments are always active if configured
-
render json: { message: "Estado actualizado" }
-
end
-
-
private
-
-
def require_admin_or_hr
-
unless current_user.admin? || current_user.has_role?("hr") || current_user.has_role?("hr_manager")
-
render json: { error: "No autorizado" }, status: :forbidden
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Admin
-
class SettingsController < BaseController
-
before_action :set_organization
-
-
def show
-
authorize :settings, :show?
-
-
render json: {
-
data: organization_settings
-
}, status: :ok
-
end
-
-
def update
-
authorize :settings, :update?
-
-
if @organization.update(organization_params)
-
render json: {
-
message: "Configuración actualizada correctamente",
-
data: organization_settings
-
}, status: :ok
-
else
-
render json: {
-
error: "Error al actualizar configuración",
-
errors: @organization.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def set_organization
-
@organization = current_organization
-
render_error("Organización no encontrada", status: :not_found) unless @organization
-
end
-
-
def organization_params
-
params.require(:settings).permit(
-
:name, :legal_name, :tax_id, :address, :city, :country,
-
:phone, :email, :website, :logo_url,
-
:vacation_days_per_year, :vacation_accrual_policy,
-
:max_vacation_carryover, :probation_period_months,
-
:max_file_size_mb, :document_retention_years,
-
:session_timeout_minutes, :password_min_length,
-
:password_require_uppercase, :password_require_number,
-
:password_require_special, :max_login_attempts,
-
allowed_file_types: []
-
)
-
end
-
-
def organization_settings
-
{
-
# System info
-
system: {
-
app_name: "VALKYRIA ECM",
-
version: "1.0.0",
-
environment: Rails.env
-
},
-
# Organization details
-
organization: {
-
id: @organization.uuid,
-
name: @organization.name,
-
legal_name: @organization.legal_name,
-
tax_id: @organization.tax_id,
-
address: @organization.address,
-
city: @organization.city,
-
country: @organization.country,
-
phone: @organization.phone,
-
email: @organization.email,
-
website: @organization.website,
-
logo_url: @organization.logo_url
-
},
-
# HR Settings
-
hr: {
-
vacation_days_per_year: @organization.vacation_days_per_year,
-
vacation_accrual_policy: @organization.vacation_accrual_policy,
-
max_vacation_carryover: @organization.max_vacation_carryover,
-
probation_period_months: @organization.probation_period_months
-
},
-
# Document Settings
-
documents: {
-
allowed_file_types: @organization.allowed_file_types,
-
max_file_size_mb: @organization.max_file_size_mb,
-
document_retention_years: @organization.document_retention_years
-
},
-
# Security Settings
-
security: {
-
session_timeout_minutes: @organization.session_timeout_minutes,
-
password_min_length: @organization.password_min_length,
-
password_require_uppercase: @organization.password_require_uppercase,
-
password_require_number: @organization.password_require_number,
-
password_require_special: @organization.password_require_special,
-
max_login_attempts: @organization.max_login_attempts
-
}
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Admin
-
class SignatoryTypesController < BaseController
-
before_action :ensure_admin_or_hr
-
before_action :set_signatory_type, only: [:show, :update, :destroy, :toggle_active]
-
-
# GET /api/v1/admin/signatory_types
-
def index
-
@types = ::Templates::SignatoryType
-
.for_organization(current_organization)
-
.ordered
-
-
# Filter by active status
-
if params[:active].present?
-
@types = params[:active] == "true" ? @types.active : @types.inactive
-
end
-
-
# Filter system vs custom
-
if params[:type].present?
-
@types = params[:type] == "system" ? @types.system_types : @types.custom_types
-
end
-
-
render json: {
-
data: @types.map { |t| type_json(t) },
-
meta: {
-
total: @types.count
-
}
-
}
-
end
-
-
# GET /api/v1/admin/signatory_types/:id
-
def show
-
render json: { data: type_json(@signatory_type) }
-
end
-
-
# POST /api/v1/admin/signatory_types
-
def create
-
@signatory_type = ::Templates::SignatoryType.new(type_params)
-
@signatory_type.organization = current_organization
-
@signatory_type.created_by = current_user
-
@signatory_type.is_system = false
-
-
if @signatory_type.save
-
render json: {
-
data: type_json(@signatory_type),
-
message: "Tipo de firmante creado exitosamente"
-
}, status: :created
-
else
-
render json: {
-
error: "Error al crear tipo de firmante",
-
errors: @signatory_type.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# PATCH /api/v1/admin/signatory_types/:id
-
def update
-
if @signatory_type.system?
-
return render json: {
-
error: "No se pueden modificar tipos de firmante del sistema"
-
}, status: :forbidden
-
end
-
-
if @signatory_type.update(type_params)
-
render json: {
-
data: type_json(@signatory_type),
-
message: "Tipo de firmante actualizado exitosamente"
-
}
-
else
-
render json: {
-
error: "Error al actualizar tipo de firmante",
-
errors: @signatory_type.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# DELETE /api/v1/admin/signatory_types/:id
-
def destroy
-
if @signatory_type.system?
-
return render json: {
-
error: "No se pueden eliminar tipos de firmante del sistema"
-
}, status: :forbidden
-
end
-
-
if @signatory_type.in_use?
-
return render json: {
-
error: "No se puede eliminar este tipo de firmante porque está siendo usado en #{@signatory_type.usage_count} plantilla(s)"
-
}, status: :conflict
-
end
-
-
@signatory_type.destroy
-
render json: { message: "Tipo de firmante eliminado exitosamente" }
-
end
-
-
# POST /api/v1/admin/signatory_types/:id/toggle_active
-
def toggle_active
-
@signatory_type.toggle_active!
-
-
render json: {
-
data: type_json(@signatory_type),
-
message: @signatory_type.active? ? "Tipo de firmante activado" : "Tipo de firmante desactivado"
-
}
-
end
-
-
# POST /api/v1/admin/signatory_types/seed_system
-
def seed_system
-
::Templates::SignatoryType.seed_system_types!
-
-
render json: {
-
message: "Tipos de firmante del sistema creados exitosamente",
-
count: ::Templates::SignatoryType.system_types.count
-
}
-
end
-
-
# POST /api/v1/admin/signatory_types/reorder
-
def reorder
-
return render json: { error: "Se requiere lista de IDs" }, status: :bad_request unless params[:ids].present?
-
-
params[:ids].each_with_index do |uuid, index|
-
type = ::Templates::SignatoryType.find_by(uuid: uuid)
-
type&.update!(position: index)
-
end
-
-
render json: { message: "Orden actualizado" }
-
end
-
-
private
-
-
def ensure_admin_or_hr
-
return if current_user.admin? || current_user.has_role?("hr")
-
-
render json: {
-
error: "Acceso denegado. Se requieren privilegios de administrador o HR."
-
}, status: :forbidden
-
end
-
-
def set_signatory_type
-
@signatory_type = ::Templates::SignatoryType.find_by(uuid: params[:id])
-
-
return if @signatory_type
-
-
render json: { error: "Tipo de firmante no encontrado" }, status: :not_found
-
end
-
-
def type_params
-
params.require(:signatory_type).permit(
-
:name,
-
:code,
-
:description,
-
:active,
-
:position
-
)
-
end
-
-
def type_json(type)
-
{
-
id: type.uuid,
-
name: type.name,
-
code: type.code,
-
description: type.description,
-
is_system: type.is_system,
-
active: type.active,
-
position: type.position,
-
in_use: type.in_use?,
-
usage_count: type.usage_count,
-
created_at: type.created_at.iso8601
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Admin
-
class TemplateSignatoriesController < BaseController
-
before_action :ensure_admin_or_hr
-
before_action :set_template
-
before_action :set_signatory, only: [:show, :update, :destroy]
-
-
# GET /api/v1/admin/templates/:template_id/signatories
-
def index
-
@signatories = @template.signatories.by_position
-
-
render json: {
-
data: @signatories.map { |s| signatory_json(s) },
-
meta: {
-
total: @signatories.count,
-
roles: ::Templates::TemplateSignatory::ROLE_LABELS
-
}
-
}
-
end
-
-
# GET /api/v1/admin/templates/:template_id/signatories/:id
-
def show
-
render json: { data: signatory_json(@signatory) }
-
end
-
-
# POST /api/v1/admin/templates/:template_id/signatories
-
def create
-
@signatory = @template.signatories.build(signatory_params)
-
-
# Set position to end if not specified
-
@signatory.position ||= @template.signatories.count
-
-
if @signatory.save
-
render json: {
-
data: signatory_json(@signatory),
-
message: "Firmante agregado exitosamente"
-
}, status: :created
-
else
-
render json: {
-
error: "Error al agregar firmante",
-
errors: @signatory.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# PATCH /api/v1/admin/templates/:template_id/signatories/:id
-
def update
-
if @signatory.update(signatory_params)
-
render json: {
-
data: signatory_json(@signatory),
-
message: "Firmante actualizado exitosamente"
-
}
-
else
-
render json: {
-
error: "Error al actualizar firmante",
-
errors: @signatory.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# DELETE /api/v1/admin/templates/:template_id/signatories/:id
-
def destroy
-
@signatory.destroy
-
render json: { message: "Firmante eliminado exitosamente" }
-
end
-
-
# POST /api/v1/admin/templates/:template_id/signatories/reorder
-
def reorder
-
return render json: { error: "Se requiere lista de IDs" }, status: :bad_request unless params[:ids].present?
-
-
params[:ids].each_with_index do |uuid, index|
-
next if uuid.blank?
-
signatory = @template.signatories.where(uuid: uuid).first
-
signatory&.update!(position: index)
-
end
-
-
render json: {
-
data: @template.signatories.by_position.map { |s| signatory_json(s) },
-
message: "Orden actualizado"
-
}
-
end
-
-
private
-
-
def ensure_admin_or_hr
-
return if current_user.admin? || current_user.has_role?("hr")
-
-
render json: {
-
error: "Acceso denegado. Se requieren privilegios de administrador o HR."
-
}, status: :forbidden
-
end
-
-
def set_template
-
return render json: { error: "ID de template requerido" }, status: :bad_request if params[:template_id].blank?
-
-
@template = ::Templates::Template.where(
-
uuid: params[:template_id],
-
organization_id: current_organization.id
-
).first
-
-
return if @template
-
-
render json: { error: "Template no encontrado" }, status: :not_found
-
end
-
-
def set_signatory
-
return render json: { error: "ID de firmante requerido" }, status: :bad_request if params[:id].blank?
-
-
@signatory = @template.signatories.where(uuid: params[:id]).first
-
-
return if @signatory
-
-
render json: { error: "Firmante no encontrado" }, status: :not_found
-
end
-
-
def signatory_params
-
params.require(:signatory).permit(
-
:role,
-
:signatory_type_code,
-
:label,
-
:position,
-
:required,
-
:placeholder_text,
-
:page_number,
-
:x_position,
-
:y_position,
-
:width,
-
:height,
-
:date_position,
-
:show_label,
-
:show_signer_name,
-
:custom_user_id,
-
:custom_email
-
)
-
end
-
-
def signatory_json(signatory)
-
{
-
id: signatory.uuid,
-
role: signatory.role,
-
signatory_type_code: signatory.signatory_type_code,
-
effective_code: signatory.effective_code,
-
role_label: signatory.role_label,
-
label: signatory.label,
-
position: signatory.position,
-
required: signatory.required,
-
placeholder_text: signatory.placeholder_text,
-
page_number: signatory.page_number,
-
x_position: signatory.x_position,
-
y_position: signatory.y_position,
-
width: signatory.width,
-
height: signatory.height,
-
date_position: signatory.date_position || "right",
-
show_label: signatory.show_label.nil? ? true : signatory.show_label,
-
show_signer_name: signatory.show_signer_name || false,
-
custom_user_id: signatory.custom_user_id&.to_s,
-
custom_email: signatory.custom_email,
-
created_at: signatory.created_at.iso8601
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Admin
-
class TemplatesController < BaseController
-
before_action :ensure_admin_or_hr
-
before_action :set_template, only: [:show, :update, :destroy, :activate, :archive, :duplicate, :reassign_mappings, :download, :preview]
-
-
# GET /api/v1/admin/templates
-
def index
-
@templates = ::Templates::Template
-
.for_organization(current_organization)
-
.order(created_at: :desc)
-
-
# Filter by status
-
@templates = @templates.where(status: params[:status]) if params[:status].present?
-
-
# Filter by module
-
@templates = @templates.by_module(params[:module_type]) if params[:module_type].present?
-
-
# Filter by main category
-
@templates = @templates.by_main_category(params[:main_category]) if params[:main_category].present?
-
-
# Filter by subcategory
-
@templates = @templates.where(category: params[:category]) if params[:category].present?
-
-
# Search by name
-
if params[:q].present?
-
@templates = @templates.where(name: /#{Regexp.escape(params[:q])}/i)
-
end
-
-
render json: {
-
data: @templates.map { |t| template_json(t) },
-
meta: {
-
total: @templates.count,
-
categories: ::Templates::Template::CATEGORIES,
-
statuses: ::Templates::Template::STATUSES
-
}
-
}
-
end
-
-
# GET /api/v1/admin/templates/:id
-
def show
-
render json: {
-
data: template_json(@template, detailed: true)
-
}
-
end
-
-
# POST /api/v1/admin/templates
-
def create
-
@template = ::Templates::Template.new(template_params)
-
@template.organization = current_organization
-
@template.created_by = current_user
-
-
if @template.save
-
# Handle file upload if present
-
handle_file_upload if params[:file].present?
-
-
render json: {
-
data: template_json(@template),
-
message: "Template creado exitosamente"
-
}, status: :created
-
else
-
render json: {
-
error: "Error al crear template",
-
errors: @template.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# PATCH /api/v1/admin/templates/:id
-
def update
-
if @template.update(template_params)
-
# Handle file upload if present
-
handle_file_upload if params[:file].present?
-
-
render json: {
-
data: template_json(@template),
-
message: "Template actualizado exitosamente"
-
}
-
else
-
render json: {
-
error: "Error al actualizar template",
-
errors: @template.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# DELETE /api/v1/admin/templates/:id
-
def destroy
-
if @template.generated_documents.completed.any?
-
render json: {
-
error: "No se puede eliminar un template con documentos generados"
-
}, status: :unprocessable_content
-
else
-
@template.destroy
-
render json: { message: "Template eliminado exitosamente" }
-
end
-
end
-
-
# POST /api/v1/admin/templates/:id/activate
-
def activate
-
@template.activate!
-
render json: {
-
data: template_json(@template),
-
message: "Template activado exitosamente"
-
}
-
rescue ::Templates::Template::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_content
-
end
-
-
# POST /api/v1/admin/templates/:id/archive
-
def archive
-
@template.archive!
-
render json: {
-
data: template_json(@template),
-
message: "Template archivado exitosamente"
-
}
-
end
-
-
# POST /api/v1/admin/templates/:id/duplicate
-
def duplicate
-
new_template = @template.duplicate!
-
render json: {
-
data: template_json(new_template),
-
message: "Template duplicado exitosamente"
-
}, status: :created
-
end
-
-
# GET /api/v1/admin/templates/categories
-
def categories
-
modules = ::Templates::Template::MODULES.map do |key, config|
-
{ value: key, label: config[:label], icon: config[:icon] }
-
end
-
-
main_categories = ::Templates::Template::MAIN_CATEGORIES.map do |key, label|
-
{ value: key, label: label, module: ::Templates::Template::CATEGORY_TO_MODULE[key] }
-
end
-
-
subcategories = ::Templates::Template::SUBCATEGORIES.map do |key, config|
-
{ value: key, label: config[:label], main_category: config[:main] }
-
end
-
-
# Group subcategories by main category for easier frontend consumption
-
grouped = subcategories.group_by { |s| s[:main_category] }
-
-
# Third party types for legal module
-
third_party_types = ::Legal::ThirdParty::TYPES.map do |type|
-
{ value: type, label: I18n.t("legal.third_party.types.#{type}", default: type.humanize) }
-
end
-
-
render json: {
-
data: subcategories, # Legacy: flat list of subcategories
-
modules: modules,
-
main_categories: main_categories,
-
subcategories: subcategories,
-
grouped: grouped,
-
category_to_module: ::Templates::Template::CATEGORY_TO_MODULE,
-
third_party_types: third_party_types
-
}
-
end
-
-
# GET /api/v1/admin/templates/:id/third_party_requirements
-
def third_party_requirements
-
set_template
-
return unless @template
-
-
render json: {
-
data: {
-
template_id: @template.uuid,
-
template_name: @template.name,
-
default_third_party_type: @template.default_third_party_type,
-
suggested_person_type: @template.suggested_person_type,
-
required_fields: @template.required_third_party_fields,
-
uses_third_party: @template.uses_third_party_variables?,
-
variables: @template.variables,
-
variables_count: @template.variables&.count || 0
-
}
-
}
-
end
-
-
# GET /api/v1/admin/templates/variable_mappings
-
def variable_mappings
-
render json: {
-
data: ::Templates::Template.available_variable_mappings(current_organization),
-
grouped: ::Templates::Template.grouped_variable_mappings(current_organization).transform_values do |mappings|
-
mappings.map { |m| { name: m.name, key: m.key, description: m.description } }
-
end
-
}
-
end
-
-
# POST /api/v1/admin/templates/:id/upload
-
def upload
-
set_template
-
-
unless params[:file].present?
-
return render json: { error: "Archivo requerido" }, status: :bad_request
-
end
-
-
handle_file_upload
-
-
render json: {
-
data: template_json(@template),
-
message: "Archivo subido exitosamente",
-
variables: @template.variables
-
}
-
end
-
-
# POST /api/v1/admin/templates/:id/reassign_mappings
-
def reassign_mappings
-
@template.reassign_all_mappings!
-
-
render json: {
-
data: template_json(@template, detailed: true),
-
message: "Mappings reasignados exitosamente"
-
}
-
end
-
-
# GET /api/v1/admin/templates/:id/download
-
def download
-
unless @template.file_id
-
return render json: { error: "El template no tiene archivo adjunto" }, status: :not_found
-
end
-
-
file_content = @template.file_content
-
-
if file_content
-
send_data file_content,
-
filename: @template.file_name || "#{@template.name}.docx",
-
type: @template.file_content_type || "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-
disposition: "attachment"
-
else
-
render json: { error: "No se pudo obtener el archivo" }, status: :internal_server_error
-
end
-
end
-
-
# GET /api/v1/admin/templates/:id/preview
-
def preview
-
unless @template.file_id
-
return render json: { error: "El template no tiene archivo adjunto" }, status: :not_found
-
end
-
-
# First, try to use stored PDF preview (works on Heroku without LibreOffice)
-
if @template.preview_file_id
-
preview_content = @template.preview_content
-
if preview_content
-
return send_data preview_content,
-
filename: "#{@template.name || 'preview'}.pdf",
-
type: "application/pdf",
-
disposition: "inline"
-
end
-
end
-
-
# If file is already a PDF, serve it directly
-
if @template.file_name&.end_with?(".pdf")
-
file_content = @template.file_content
-
if file_content
-
return send_data file_content,
-
filename: @template.file_name,
-
type: "application/pdf",
-
disposition: "inline"
-
end
-
end
-
-
file_content = @template.file_content
-
-
unless file_content
-
return render json: { error: "No se pudo obtener el archivo" }, status: :internal_server_error
-
end
-
-
# Convert Word to PDF using LibreOffice (local development)
-
temp_dir = Dir.mktmpdir
-
begin
-
# Write Word file
-
docx_path = File.join(temp_dir, "template.docx")
-
File.binwrite(docx_path, file_content)
-
-
# Convert to PDF using LibreOffice
-
soffice_paths = [
-
`which soffice`.strip,
-
"/opt/homebrew/bin/soffice", # macOS Homebrew
-
"/usr/bin/soffice", # Linux standard
-
]
-
-
soffice_path = soffice_paths.find { |p| p.present? && File.exist?(p) }
-
-
unless soffice_path
-
# No LibreOffice and no stored preview - return error
-
return render json: { error: "Preview PDF no disponible. Re-sube el archivo desde un entorno con LibreOffice." }, status: :service_unavailable
-
end
-
-
system(soffice_path, "--headless", "--convert-to", "pdf", "--outdir", temp_dir, docx_path)
-
-
pdf_path = File.join(temp_dir, "template.pdf")
-
-
unless File.exist?(pdf_path)
-
return render json: { error: "Error al convertir el documento a PDF" }, status: :internal_server_error
-
end
-
-
pdf_content = File.binread(pdf_path)
-
-
# Store this preview for future use
-
@template.store_pdf_preview!(pdf_content)
-
@template.save
-
-
send_data pdf_content,
-
filename: "#{@template.name || 'preview'}.pdf",
-
type: "application/pdf",
-
disposition: "inline"
-
ensure
-
FileUtils.rm_rf(temp_dir)
-
end
-
end
-
-
private
-
-
def ensure_admin_or_hr
-
return if current_user.admin? || current_user.has_role?("hr")
-
-
render json: {
-
error: "Acceso denegado. Se requieren privilegios de administrador o HR."
-
}, status: :forbidden
-
end
-
-
def set_template
-
@template = ::Templates::Template.find_by(
-
uuid: params[:id],
-
organization_id: current_organization.id
-
)
-
-
return if @template
-
-
render json: { error: "Template no encontrado" }, status: :not_found
-
end
-
-
def template_params
-
params.require(:template).permit(
-
:name,
-
:description,
-
:module_type,
-
:main_category,
-
:category,
-
:certification_type,
-
:default_third_party_type,
-
:preview_scale,
-
:preview_page_height,
-
:sequential_signing,
-
variable_mappings: {}
-
)
-
end
-
-
def handle_file_upload
-
file = params[:file]
-
-
# Validate file type
-
unless file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-
@template.errors.add(:file, "debe ser un documento Word (.docx)")
-
return
-
end
-
-
# Validate file size (10MB max)
-
if file.size > 10.megabytes
-
@template.errors.add(:file, "no debe exceder 10MB")
-
return
-
end
-
-
@template.attach_file(
-
file.tempfile,
-
filename: file.original_filename,
-
content_type: file.content_type
-
)
-
end
-
-
def template_json(template, detailed: false)
-
json = {
-
id: template.uuid,
-
name: template.name,
-
description: template.description,
-
module_type: template.module_type,
-
module_type_label: template.module_type_label,
-
main_category: template.main_category,
-
main_category_label: template.main_category_label,
-
category: template.category,
-
category_label: template.category_label,
-
status: template.status,
-
version: template.version,
-
file_name: template.file_name,
-
file_size: template.file_size,
-
variables: template.variables,
-
signatories_count: template.signatories.count,
-
certification_type: template.certification_type,
-
default_third_party_type: template.default_third_party_type,
-
uses_third_party: template.uses_third_party_variables?,
-
sequential_signing: template.sequential_signing != false,
-
preview_scale: template.preview_scale || 0.7,
-
preview_page_height: template.preview_page_height || 842,
-
pdf_width: template.pdf_width || 612,
-
pdf_height: template.pdf_height || 792,
-
pdf_page_count: template.pdf_page_count || 1,
-
created_at: template.created_at.iso8601,
-
updated_at: template.updated_at.iso8601
-
}
-
-
if detailed
-
json[:variable_mappings] = template.variable_mappings
-
json[:signatories] = template.signatories.by_position.map { |s| signatory_json(s) }
-
json[:available_mappings] = ::Templates::Template.available_variable_mappings(current_organization)
-
json[:required_third_party_fields] = template.required_third_party_fields
-
json[:suggested_person_type] = template.suggested_person_type
-
end
-
-
json
-
end
-
-
def signatory_json(signatory)
-
{
-
id: signatory.uuid,
-
role: signatory.role,
-
signatory_type_code: signatory.signatory_type_code,
-
effective_code: signatory.effective_code,
-
role_label: signatory.role_label,
-
label: signatory.label,
-
position: signatory.position,
-
required: signatory.required,
-
placeholder_text: signatory.placeholder_text,
-
page_number: signatory.page_number,
-
x_position: signatory.x_position,
-
y_position: signatory.y_position,
-
width: signatory.width,
-
height: signatory.height,
-
date_position: signatory.date_position || "right",
-
show_label: signatory.show_label.nil? ? true : signatory.show_label,
-
show_signer_name: signatory.show_signer_name || false
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Admin
-
class UsersController < BaseController
-
before_action :require_admin
-
before_action :set_user, only: [:show, :update, :destroy, :toggle_active, :assign_roles]
-
-
# GET /api/v1/admin/users
-
def index
-
users = policy_scope(::Identity::User).enabled
-
-
# Filter by search
-
if params[:search].present?
-
search = params[:search].downcase
-
users = users.or(
-
{ first_name: /#{search}/i },
-
{ last_name: /#{search}/i },
-
{ email: /#{search}/i }
-
)
-
end
-
-
# Filter by role
-
if params[:role].present?
-
role = ::Identity::Role.find_by(name: params[:role])
-
users = users.where(:role_ids.in => [role&.id].compact) if role
-
end
-
-
# Filter by permission level
-
if params[:level].present?
-
level = params[:level].to_i
-
role_names = ::Identity::Role::ROLE_LEVELS.select { |_, v| v == level }.keys
-
roles = ::Identity::Role.where(:name.in => role_names)
-
users = users.where(:role_ids.in => roles.pluck(:id)) if roles.any?
-
end
-
-
# Filter by status
-
if params[:status].present?
-
users = params[:status] == 'active' ? users.enabled : users.disabled
-
end
-
-
# Sorting
-
sort_by = params[:sort_by] || 'created_at'
-
sort_dir = params[:sort_direction] == 'asc' ? 1 : -1
-
users = users.order(sort_by => sort_dir)
-
-
# Pagination
-
page = (params[:page] || 1).to_i
-
per_page = (params[:per_page] || 20).to_i
-
total = users.count
-
users = users.skip((page - 1) * per_page).limit(per_page)
-
-
render json: {
-
data: users.map { |u| user_response(u) },
-
meta: {
-
total: total,
-
page: page,
-
per_page: per_page,
-
total_pages: (total.to_f / per_page).ceil
-
}
-
}
-
end
-
-
# GET /api/v1/admin/users/:id
-
def show
-
render json: { data: user_response(@user, full: true) }
-
end
-
-
# POST /api/v1/admin/users
-
def create
-
@user = ::Identity::User.new(user_params)
-
@user.organization = current_organization
-
@user.password = params[:user][:password] || SecureRandom.hex(8)
-
@user.must_change_password = true
-
-
if @user.save
-
# Assign roles
-
assign_roles_to_user(@user, params[:user][:role_names])
-
-
render json: {
-
data: user_response(@user),
-
message: "Usuario creado correctamente"
-
}, status: :created
-
else
-
render json: { error: @user.errors.full_messages.join(", ") }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /api/v1/admin/users/:id
-
def update
-
if @user.update(user_params)
-
# Update roles if provided
-
if params[:user][:role_names].present?
-
assign_roles_to_user(@user, params[:user][:role_names])
-
end
-
-
render json: {
-
data: user_response(@user),
-
message: "Usuario actualizado correctamente"
-
}
-
else
-
render json: { error: @user.errors.full_messages.join(", ") }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/admin/users/:id
-
def destroy
-
if @user == current_user
-
return render json: { error: "No puedes eliminar tu propio usuario" }, status: :unprocessable_entity
-
end
-
-
@user.soft_delete!
-
render json: { message: "Usuario eliminado correctamente" }
-
end
-
-
# POST /api/v1/admin/users/:id/toggle_active
-
def toggle_active
-
if @user == current_user
-
return render json: { error: "No puedes desactivar tu propio usuario" }, status: :unprocessable_entity
-
end
-
-
if @user.active?
-
@user.deactivate!
-
message = "Usuario desactivado"
-
else
-
@user.activate!
-
message = "Usuario activado"
-
end
-
-
render json: { data: user_response(@user), message: message }
-
end
-
-
# POST /api/v1/admin/users/:id/assign_roles
-
def assign_roles
-
role_names = params[:role_names] || []
-
assign_roles_to_user(@user, role_names)
-
-
render json: {
-
data: user_response(@user),
-
message: "Roles actualizados correctamente"
-
}
-
end
-
-
# GET /api/v1/admin/users/roles
-
def roles
-
roles = ::Identity::Role.all.by_level.map do |role|
-
{
-
name: role.name,
-
display_name: role.display_name,
-
description: role.description,
-
level: role.level_value,
-
system_role: role.system_role
-
}
-
end
-
-
render json: { data: roles }
-
end
-
-
# GET /api/v1/admin/users/stats
-
def stats
-
users = ::Identity::User.where(organization_id: current_organization.id)
-
-
stats = {
-
total: users.count,
-
active: users.enabled.count,
-
inactive: users.disabled.count,
-
by_role: {},
-
by_level: {}
-
}
-
-
# Count by role
-
::Identity::Role.all.each do |role|
-
count = users.where(:role_ids.in => [role.id]).count
-
stats[:by_role][role.name] = count if count > 0
-
end
-
-
# Count by level
-
(1..5).each do |level|
-
role_name = ::Identity::Role::LEVELS[level]
-
role = ::Identity::Role.find_by(name: role_name)
-
stats[:by_level][level] = users.where(:role_ids.in => [role&.id].compact).count if role
-
end
-
-
render json: { data: stats }
-
end
-
-
private
-
-
def set_user
-
@user = ::Identity::User.find(params[:id])
-
end
-
-
def user_params
-
params.require(:user).permit(
-
:email, :first_name, :last_name, :employee_id,
-
:department, :title, :phone, :time_zone, :locale, :active
-
)
-
end
-
-
def require_admin
-
unless current_user.admin?
-
render json: { error: "No autorizado. Se requiere rol de administrador." }, status: :forbidden
-
end
-
end
-
-
def assign_roles_to_user(user, role_names)
-
return if role_names.nil?
-
-
# Clear existing roles
-
user.roles = []
-
-
# Assign new roles
-
role_names.each do |role_name|
-
role = ::Identity::Role.find_by(name: role_name)
-
user.roles << role if role
-
end
-
-
user.save!
-
end
-
-
def user_response(user, full: false)
-
# Get employee data if exists
-
employee = ::Hr::Employee.find_by(user_id: user.id)
-
-
# Use employee's department/title if user's is empty
-
department = user.department.presence || employee&.department
-
title = user.title.presence || employee&.job_title
-
-
response = {
-
id: user.id.to_s,
-
email: user.email,
-
first_name: user.first_name,
-
last_name: user.last_name,
-
full_name: user.full_name,
-
department: department,
-
title: title,
-
active: user.active,
-
roles: user.role_names,
-
permission_level: user.permission_level,
-
level_name: user.level_name,
-
created_at: user.created_at&.iso8601,
-
last_sign_in_at: user.last_sign_in_at&.iso8601,
-
has_employee: employee.present?,
-
employee_id: employee&.id&.to_s
-
}
-
-
if full
-
response.merge!(
-
employee_id: user.employee_id,
-
phone: user.phone,
-
time_zone: user.time_zone,
-
locale: user.locale,
-
sign_in_count: user.sign_in_count,
-
must_change_password: user.must_change_password,
-
organization_id: user.organization_id&.to_s
-
)
-
end
-
-
response
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Admin
-
class VariableMappingsController < BaseController
-
before_action :ensure_admin_or_hr
-
before_action :set_mapping, only: [:show, :update, :destroy, :toggle_active]
-
-
# GET /api/v1/admin/variable_mappings
-
def index
-
@mappings = ::Templates::VariableMapping
-
.for_organization(current_organization)
-
.ordered
-
-
# Filter by category
-
@mappings = @mappings.by_category(params[:category]) if params[:category].present?
-
-
# Filter by active status
-
if params[:active].present?
-
@mappings = params[:active] == "true" ? @mappings.active : @mappings.inactive
-
end
-
-
# Filter system vs custom
-
if params[:type].present?
-
@mappings = params[:type] == "system" ? @mappings.system_mappings : @mappings.custom_mappings
-
end
-
-
render json: {
-
data: @mappings.map { |m| mapping_json(m) },
-
meta: {
-
total: @mappings.count,
-
categories: ::Templates::VariableMapping::CATEGORIES,
-
data_types: ::Templates::VariableMapping::DATA_TYPES
-
}
-
}
-
end
-
-
# GET /api/v1/admin/variable_mappings/grouped
-
def grouped
-
grouped = ::Templates::VariableMapping.grouped_for(current_organization)
-
-
render json: {
-
data: grouped.transform_values { |mappings| mappings.map { |m| mapping_json(m) } }
-
}
-
end
-
-
# GET /api/v1/admin/variable_mappings/:id
-
def show
-
render json: { data: mapping_json(@mapping) }
-
end
-
-
# POST /api/v1/admin/variable_mappings
-
def create
-
@mapping = ::Templates::VariableMapping.new(mapping_params)
-
@mapping.organization = current_organization
-
@mapping.created_by = current_user
-
@mapping.is_system = false
-
-
if @mapping.save
-
render json: {
-
data: mapping_json(@mapping),
-
message: "Mapeo creado exitosamente"
-
}, status: :created
-
else
-
render json: {
-
error: "Error al crear mapeo",
-
errors: @mapping.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# PATCH /api/v1/admin/variable_mappings/:id
-
def update
-
if @mapping.update(mapping_params)
-
render json: {
-
data: mapping_json(@mapping),
-
message: "Mapeo actualizado exitosamente"
-
}
-
else
-
render json: {
-
error: "Error al actualizar mapeo",
-
errors: @mapping.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# DELETE /api/v1/admin/variable_mappings/:id
-
def destroy
-
# Only admins can delete system mappings
-
if @mapping.system? && !current_user.admin?
-
return render json: {
-
error: "Solo administradores pueden eliminar mapeos del sistema"
-
}, status: :forbidden
-
end
-
-
@mapping.destroy
-
render json: { message: "Mapeo eliminado exitosamente" }
-
end
-
-
# POST /api/v1/admin/variable_mappings/:id/toggle_active
-
def toggle_active
-
@mapping.toggle_active!
-
-
render json: {
-
data: mapping_json(@mapping),
-
message: @mapping.active? ? "Mapeo activado" : "Mapeo desactivado"
-
}
-
end
-
-
# POST /api/v1/admin/variable_mappings/seed_system
-
def seed_system
-
::Templates::VariableMapping.seed_system_mappings!
-
-
render json: {
-
message: "Mapeos del sistema creados exitosamente",
-
count: ::Templates::VariableMapping.system_mappings.count
-
}
-
end
-
-
# POST /api/v1/admin/variable_mappings/reorder
-
def reorder
-
return render json: { error: "Se requiere lista de IDs" }, status: :bad_request unless params[:ids].present?
-
-
params[:ids].each_with_index do |uuid, index|
-
mapping = ::Templates::VariableMapping.find_by(uuid: uuid)
-
mapping&.update!(position: index)
-
end
-
-
render json: { message: "Orden actualizado" }
-
end
-
-
# GET /api/v1/admin/variable_mappings/pending_variables
-
# Params: module_type (hr, legal, admin) - filter templates by module
-
def pending_variables
-
templates = ::Templates::Template.for_organization(current_organization)
-
templates = templates.by_module(params[:module_type]) if params[:module_type].present?
-
available_mappings = ::Templates::VariableMapping.available_for(current_organization)
-
-
pending_data = []
-
-
templates.each do |template|
-
next if template.variables.blank?
-
-
template.variables.each do |variable|
-
# Check if this variable has a mapping assigned
-
has_mapping = template.variable_mappings[variable].present?
-
next if has_mapping
-
-
# Find suggestions based on similarity
-
suggestions = find_suggestions(variable, available_mappings)
-
-
pending_data << {
-
template_id: template.uuid,
-
template_name: template.name,
-
template_category: template.category_label,
-
template_status: template.status,
-
variable: variable,
-
suggestions: suggestions
-
}
-
end
-
end
-
-
# Group by variable name
-
grouped = pending_data.group_by { |p| p[:variable] }
-
-
render json: {
-
data: {
-
pending_variables: pending_data,
-
grouped_by_variable: grouped.transform_values do |items|
-
{
-
count: items.size,
-
templates: items.map { |i| { id: i[:template_id], name: i[:template_name] } },
-
suggestions: items.first[:suggestions]
-
}
-
end,
-
summary: {
-
total_pending: pending_data.size,
-
unique_variables: grouped.keys.size,
-
templates_with_pending: pending_data.map { |p| p[:template_id] }.uniq.size
-
}
-
}
-
}
-
end
-
-
# POST /api/v1/admin/variable_mappings/auto_assign
-
def auto_assign
-
variable_name = params[:variable]
-
mapping_key = params[:mapping_key]
-
template_ids = params[:template_ids] || []
-
-
return render json: { error: "Se requiere variable y mapping_key" }, status: :bad_request if variable_name.blank? || mapping_key.blank?
-
-
# Normalize the variable name to match stored format
-
normalized_variable = ::Templates::VariableNormalizer.normalize(variable_name)
-
-
updated_count = 0
-
-
templates = if template_ids.present?
-
::Templates::Template.where(:uuid.in => template_ids)
-
else
-
::Templates::Template.for_organization(current_organization)
-
end
-
-
templates.each do |template|
-
next unless template.variables&.include?(normalized_variable)
-
next if template.variable_mappings[normalized_variable].present?
-
-
template.variable_mappings[normalized_variable] = mapping_key
-
template.save!
-
updated_count += 1
-
end
-
-
render json: {
-
message: "Mapeo asignado exitosamente",
-
updated_templates: updated_count
-
}
-
end
-
-
# POST /api/v1/admin/variable_mappings/merge
-
# Merge variables: keep primary, convert others to aliases (same key, different names)
-
def merge
-
primary_id = params[:primary_id]
-
alias_ids = params[:alias_ids] || []
-
-
return render json: { error: "Se requiere primary_id" }, status: :bad_request if primary_id.blank?
-
return render json: { error: "Se requiere al menos un alias_id" }, status: :bad_request if alias_ids.empty?
-
-
primary = ::Templates::VariableMapping.find_by(uuid: primary_id)
-
return render json: { error: "Variable principal no encontrada" }, status: :not_found unless primary
-
-
merged_names = [primary.name]
-
merged_count = 0
-
-
alias_ids.each do |alias_id|
-
alias_mapping = ::Templates::VariableMapping.find_by(uuid: alias_id)
-
next unless alias_mapping
-
next if alias_mapping.uuid == primary.uuid
-
next if alias_mapping.key == primary.key # Already linked
-
-
old_key = alias_mapping.key
-
-
# Update the alias mapping to use the primary's key
-
alias_mapping.update!(
-
key: primary.key,
-
description: "Alias de #{primary.name}"
-
)
-
-
# Update any templates using the old key for this variable name
-
update_templates_with_mapping(alias_mapping.name, primary.key)
-
-
merged_names << alias_mapping.name
-
merged_count += 1
-
end
-
-
render json: {
-
message: "Variables fusionadas exitosamente",
-
primary: mapping_json(primary),
-
merged_count: merged_count,
-
aliases: merged_names
-
}
-
end
-
-
# GET /api/v1/admin/variable_mappings/aliases
-
# Get all variables that have aliases
-
def aliases
-
mappings = ::Templates::VariableMapping.available_for(current_organization)
-
.select { |m| m.aliases.present? && m.aliases.any? }
-
-
render json: {
-
data: mappings.map { |m| mapping_json(m) },
-
meta: {
-
total_with_aliases: mappings.size,
-
total_aliases: mappings.sum { |m| m.aliases.size }
-
}
-
}
-
end
-
-
# POST /api/v1/admin/variable_mappings/:id/add_alias
-
# Add an alias to an existing variable
-
def add_alias
-
mapping = ::Templates::VariableMapping.find_by(uuid: params[:id])
-
return render json: { error: "Variable no encontrada" }, status: :not_found unless mapping
-
-
alias_name = params[:alias_name]
-
return render json: { error: "Se requiere alias_name" }, status: :bad_request if alias_name.blank?
-
-
# Normalize the alias name
-
normalized_name = ::Templates::VariableNormalizer.normalize(alias_name)
-
-
# Check if alias already exists in this mapping
-
if mapping.name == normalized_name || mapping.aliases.include?(normalized_name)
-
return render json: { error: "Este alias ya existe en esta variable" }, status: :unprocessable_content
-
end
-
-
# Check if alias is already a name or alias in another mapping
-
existing = ::Templates::VariableMapping.find_by_name_or_alias(normalized_name)
-
if existing && existing.uuid != mapping.uuid
-
return render json: { error: "Este nombre ya existe en otra variable: #{existing.name}" }, status: :unprocessable_content
-
end
-
-
mapping.add_alias(alias_name)
-
-
render json: {
-
data: mapping_json(mapping.reload),
-
message: "Alias agregado exitosamente"
-
}
-
end
-
-
# DELETE /api/v1/admin/variable_mappings/:id/remove_alias
-
# Remove an alias from a variable
-
def remove_alias
-
mapping = ::Templates::VariableMapping.find_by(uuid: params[:id])
-
return render json: { error: "Variable no encontrada" }, status: :not_found unless mapping
-
-
alias_name = params[:alias_name]
-
return render json: { error: "Se requiere alias_name" }, status: :bad_request if alias_name.blank?
-
-
normalized_name = ::Templates::VariableNormalizer.normalize(alias_name)
-
-
unless mapping.aliases.include?(normalized_name)
-
return render json: { error: "Este alias no existe en esta variable" }, status: :unprocessable_content
-
end
-
-
mapping.remove_alias(alias_name)
-
-
render json: {
-
data: mapping_json(mapping.reload),
-
message: "Alias eliminado exitosamente"
-
}
-
end
-
-
# POST /api/v1/admin/variable_mappings/create_and_assign
-
def create_and_assign
-
variable_name = params[:variable]
-
mapping_data = params[:mapping]&.permit(:name, :key, :category, :description, :data_type) || {}
-
template_ids = params[:template_ids] || []
-
-
return render json: { error: "Se requiere variable" }, status: :bad_request if variable_name.blank?
-
-
# Normalize the variable name
-
normalized_variable = ::Templates::VariableNormalizer.normalize(variable_name)
-
-
# Generate key from normalized variable name if not provided
-
generated_key = "custom.#{::Templates::VariableNormalizer.to_key(variable_name)}"
-
-
# Create the new mapping (name will be normalized by model callback)
-
@mapping = ::Templates::VariableMapping.new(
-
name: mapping_data[:name].presence || normalized_variable,
-
key: mapping_data[:key].presence || generated_key,
-
category: mapping_data[:category].presence || "custom",
-
description: mapping_data[:description].presence || "Variable personalizada: #{normalized_variable}",
-
data_type: mapping_data[:data_type].presence || "string",
-
organization: current_organization,
-
created_by: current_user,
-
is_system: false
-
)
-
-
unless @mapping.save
-
return render json: {
-
error: "Error al crear mapeo",
-
errors: @mapping.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
-
# Assign to templates using normalized variable name
-
updated_count = 0
-
templates = if template_ids.present?
-
::Templates::Template.where(:uuid.in => template_ids)
-
else
-
::Templates::Template.for_organization(current_organization)
-
end
-
-
templates.each do |template|
-
next unless template.variables&.include?(normalized_variable)
-
next if template.variable_mappings[normalized_variable].present?
-
-
template.variable_mappings[normalized_variable] = @mapping.key
-
template.save!
-
updated_count += 1
-
end
-
-
render json: {
-
data: mapping_json(@mapping),
-
message: "Mapeo creado y asignado exitosamente",
-
updated_templates: updated_count
-
}, status: :created
-
end
-
-
private
-
-
def find_suggestions(variable, available_mappings)
-
normalized_var = normalize_string(variable)
-
-
suggestions = available_mappings.map do |mapping|
-
# Check primary name
-
name_score = calculate_similarity(normalized_var, normalize_string(mapping.name))
-
-
# Check aliases and take the best match
-
alias_scores = (mapping.aliases || []).map { |a| calculate_similarity(normalized_var, normalize_string(a)) }
-
best_alias_score = alias_scores.max || 0
-
-
# Use the best score between name and aliases
-
best_score = [name_score, best_alias_score].max
-
-
{ mapping: mapping_json(mapping), score: best_score }
-
end
-
-
# Return top 3 suggestions with score > 0.3
-
suggestions
-
.select { |s| s[:score] > 0.3 }
-
.sort_by { |s| -s[:score] }
-
.first(3)
-
.map { |s| s[:mapping].merge(match_score: (s[:score] * 100).round) }
-
end
-
-
def normalize_string(str)
-
str.to_s.downcase
-
.gsub(/[áàäâ]/, "a")
-
.gsub(/[éèëê]/, "e")
-
.gsub(/[íìïî]/, "i")
-
.gsub(/[óòöô]/, "o")
-
.gsub(/[úùüû]/, "u")
-
.gsub(/[ñ]/, "n")
-
.gsub(/[^a-z0-9]/, "")
-
end
-
-
def update_templates_with_mapping(variable_name, new_key)
-
normalized_name = ::Templates::VariableNormalizer.normalize(variable_name)
-
-
::Templates::Template.for_organization(current_organization).each do |template|
-
next unless template.variables&.include?(normalized_name)
-
-
template.variable_mappings[normalized_name] = new_key
-
template.save!
-
end
-
end
-
-
def calculate_similarity(str1, str2)
-
return 1.0 if str1 == str2
-
return 0.0 if str1.empty? || str2.empty?
-
-
# Check for substring match
-
if str1.include?(str2) || str2.include?(str1)
-
return 0.8
-
end
-
-
# Simple word overlap similarity
-
words1 = str1.scan(/[a-z]+/)
-
words2 = str2.scan(/[a-z]+/)
-
-
return 0.0 if words1.empty? || words2.empty?
-
-
common = (words1 & words2).size
-
total = [words1.size, words2.size].max
-
-
common.to_f / total
-
end
-
-
def ensure_admin_or_hr
-
return if current_user.admin? || current_user.has_role?("hr")
-
-
render json: {
-
error: "Acceso denegado. Se requieren privilegios de administrador o HR."
-
}, status: :forbidden
-
end
-
-
def set_mapping
-
@mapping = ::Templates::VariableMapping.find_by(uuid: params[:id])
-
-
return if @mapping
-
-
render json: { error: "Mapeo no encontrado" }, status: :not_found
-
end
-
-
def mapping_params
-
params.require(:mapping).permit(
-
:name,
-
:key,
-
:category,
-
:description,
-
:data_type,
-
:format_pattern,
-
:source_model,
-
:source_field,
-
:active,
-
:position,
-
aliases: []
-
)
-
end
-
-
def mapping_json(mapping)
-
{
-
id: mapping.uuid,
-
name: mapping.name,
-
key: mapping.key,
-
category: mapping.category,
-
category_label: mapping.category_label,
-
description: mapping.description,
-
data_type: mapping.data_type,
-
format_pattern: mapping.format_pattern,
-
source_model: mapping.source_model,
-
source_field: mapping.source_field,
-
is_system: mapping.is_system,
-
active: mapping.active,
-
position: mapping.position,
-
aliases: mapping.aliases || [],
-
all_names: mapping.all_names,
-
created_at: mapping.created_at.iso8601
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Auth
-
class PasswordsController < ApplicationController
-
before_action :authenticate_user!
-
-
# PATCH /api/v1/auth/password
-
def update
-
unless current_user.valid_password?(password_params[:current_password])
-
return render json: { error: "La contraseña actual es incorrecta" }, status: :unprocessable_content
-
end
-
-
if password_params[:password] != password_params[:password_confirmation]
-
return render json: { error: "Las contraseñas no coinciden" }, status: :unprocessable_content
-
end
-
-
if current_user.update(password: password_params[:password])
-
# Clear must_change_password flag if set
-
current_user.password_changed! if current_user.must_change_password?
-
render json: { message: "Contraseña actualizada exitosamente" }, status: :ok
-
else
-
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_content
-
end
-
end
-
-
# POST /api/v1/auth/password/force_change
-
# For users who must change password on first login
-
# Also updates corporate email
-
def force_change
-
unless current_user.must_change_password?
-
return render json: { error: "No se requiere cambio de contraseña" }, status: :unprocessable_content
-
end
-
-
corporate_email = force_change_params[:corporate_email]
-
if corporate_email.blank?
-
return render json: { error: "El correo corporativo es requerido" }, status: :unprocessable_content
-
end
-
-
unless corporate_email.match?(/\A[^@\s]+@[^@\s]+\z/)
-
return render json: { error: "El correo electronico no es valido" }, status: :unprocessable_content
-
end
-
-
# Check if email is already taken by another user
-
existing_user = Identity::User.where(email: corporate_email).where(:id.ne => current_user.id).first
-
if existing_user
-
return render json: { error: "El correo electronico ya esta en uso" }, status: :unprocessable_content
-
end
-
-
if force_change_params[:new_password] != force_change_params[:new_password_confirmation]
-
return render json: { error: "Las contraseñas no coinciden" }, status: :unprocessable_content
-
end
-
-
if force_change_params[:new_password].length < 8
-
return render json: { error: "La contraseña debe tener al menos 8 caracteres" }, status: :unprocessable_content
-
end
-
-
# Update user email and password
-
if current_user.update(email: corporate_email, password: force_change_params[:new_password])
-
current_user.password_changed!
-
-
# Update employee work_email if exists
-
employee = ::Hr::Employee.for_user(current_user)
-
employee&.update(work_email: corporate_email)
-
-
# Generate new token after password change
-
token = Warden::JWTAuth::UserEncoder.new.call(current_user, :identity_user, nil).first
-
-
render json: {
-
message: "Cuenta actualizada exitosamente. Tu nuevo correo es: #{corporate_email}",
-
token: token,
-
data: user_response(current_user)
-
}, status: :ok
-
else
-
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_content
-
end
-
end
-
-
private
-
-
def authenticate_user!
-
return if current_user
-
-
render json: {
-
error: "Unauthorized",
-
message: "You need to sign in or sign up before continuing."
-
}, status: :unauthorized
-
end
-
-
def current_user
-
@current_user ||= warden.authenticate(scope: :identity_user)
-
end
-
-
def password_params
-
params.permit(:current_password, :password, :password_confirmation)
-
end
-
-
def force_change_params
-
params.permit(:corporate_email, :new_password, :new_password_confirmation)
-
end
-
-
def user_response(user)
-
employee = ::Hr::Employee.for_user(user)
-
{
-
id: user.id.to_s,
-
email: user.email,
-
first_name: user.first_name,
-
last_name: user.last_name,
-
full_name: user.full_name,
-
roles: user.role_names,
-
must_change_password: user.must_change_password || false
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Auth
-
class ProfilesController < ApplicationController
-
before_action :authenticate_user!
-
-
# PATCH /api/v1/auth/profile
-
def update
-
if current_user.update(profile_params)
-
render json: { data: user_response(current_user) }, status: :ok
-
else
-
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_content
-
end
-
end
-
-
private
-
-
def authenticate_user!
-
return if current_user
-
-
render json: {
-
error: "Unauthorized",
-
message: "You need to sign in or sign up before continuing."
-
}, status: :unauthorized
-
end
-
-
def current_user
-
@current_user ||= warden.authenticate(scope: :identity_user)
-
end
-
-
def profile_params
-
params.require(:user).permit(:first_name, :last_name, :time_zone, :locale)
-
end
-
-
def user_response(user)
-
employee = ::Hr::Employee.for_user(user)
-
vacation_info = employee ? ::Hr::VacationCalculator.new(employee).summary : nil
-
-
{
-
id: user.id.to_s,
-
email: user.email,
-
first_name: user.first_name,
-
last_name: user.last_name,
-
full_name: user.full_name,
-
department: user.department,
-
title: user.title,
-
roles: user.role_names,
-
permissions: user.permission_names,
-
permission_level: user.permission_level,
-
organization_id: user.organization_id&.to_s,
-
time_zone: user.time_zone,
-
locale: user.locale,
-
is_supervisor: employee&.supervisor? || false,
-
is_hr: employee&.hr_staff? || employee&.hr_manager? || false,
-
employee: employee ? {
-
id: employee.uuid,
-
employee_number: employee.employee_number,
-
job_title: employee.job_title,
-
department: employee.department,
-
hire_date: employee.hire_date&.iso8601
-
} : nil,
-
vacation: vacation_info
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Auth
-
class SessionsController < ApplicationController
-
before_action :authenticate_user!, only: [:show, :destroy]
-
-
def show
-
render json: {
-
data: user_response(current_user)
-
}, status: :ok
-
end
-
-
def create
-
user = Identity::User.where(email: login_params[:email]&.downcase).first
-
-
if user.nil?
-
render_error("Invalid email or password", status: :unauthorized)
-
elsif !user.valid_password?(login_params[:password])
-
handle_failed_login(user)
-
elsif !user.active?
-
render_error("Account is deactivated", status: :unauthorized)
-
else
-
handle_successful_login(user)
-
end
-
end
-
-
def destroy
-
if current_user
-
# JWT revocation is handled by Warden middleware via JwtDenylist.revoke_jwt
-
render json: { message: "Logged out successfully" }, status: :ok
-
else
-
render_error("Not logged in", status: :unauthorized)
-
end
-
end
-
-
private
-
-
def login_params
-
# Support both { user: { email, password } } and { email, password } formats
-
if params[:user].present?
-
params.require(:user).permit(:email, :password)
-
else
-
params.permit(:email, :password)
-
end
-
end
-
-
def handle_successful_login(user)
-
user.update_tracked_fields!(request)
-
token = generate_jwt_token(user)
-
-
render json: {
-
data: user_response(user),
-
token: token
-
}, status: :ok
-
end
-
-
def handle_failed_login(user)
-
user&.increment_failed_attempts if user.respond_to?(:increment_failed_attempts)
-
render_error("Invalid email or password", status: :unauthorized)
-
end
-
-
def generate_jwt_token(user)
-
Warden::JWTAuth::UserEncoder.new.call(user, :identity_user, nil).first
-
end
-
-
def revoke_jwt_token
-
token = request.headers["Authorization"]&.split&.last
-
return unless token
-
-
begin
-
payload = Warden::JWTAuth::TokenDecoder.new.call(token)
-
Identity::JwtDenylist.revoke_jwt(payload, current_user)
-
rescue JWT::DecodeError
-
# Token already invalid
-
end
-
end
-
-
def user_response(user)
-
employee = ::Hr::Employee.for_user(user)
-
vacation_info = employee ? ::Hr::VacationCalculator.new(employee).summary : nil
-
-
{
-
id: user.id.to_s,
-
email: user.email,
-
first_name: user.first_name,
-
last_name: user.last_name,
-
full_name: user.full_name,
-
department: user.department,
-
title: user.title,
-
roles: user.role_names,
-
permissions: user.permission_names,
-
permission_level: user.permission_level,
-
organization_id: user.organization_id&.to_s,
-
time_zone: user.time_zone,
-
locale: user.locale,
-
is_supervisor: employee&.supervisor? || false,
-
is_hr: employee&.hr_staff? || employee&.hr_manager? || false,
-
must_change_password: user.must_change_password || false,
-
employee: employee ? {
-
id: employee.uuid,
-
employee_number: employee.employee_number,
-
job_title: employee.job_title,
-
department: employee.department,
-
hire_date: employee.hire_date&.iso8601
-
} : nil,
-
vacation: vacation_info
-
}
-
end
-
-
def current_user
-
@current_user ||= warden.authenticate(scope: :identity_user)
-
end
-
-
def authenticate_user!
-
return if current_user
-
-
render json: {
-
error: "Unauthorized",
-
message: "You need to sign in or sign up before continuing."
-
}, status: :unauthorized
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Auth
-
class SignaturesController < BaseController
-
before_action :set_signature, only: [:show, :update, :destroy, :set_default, :toggle_active]
-
-
# GET /api/v1/auth/signatures
-
def index
-
@signatures = current_user.signatures.order(created_at: :desc)
-
-
render json: {
-
data: @signatures.map { |s| signature_json(s) },
-
meta: {
-
total: @signatures.count,
-
has_default: current_user.signatures.default_signature.exists?
-
}
-
}
-
end
-
-
# GET /api/v1/auth/signatures/:id
-
def show
-
render json: { data: signature_json(@signature, include_image: true) }
-
end
-
-
# POST /api/v1/auth/signatures
-
def create
-
@signature = current_user.signatures.build(signature_params)
-
-
# Set as default if it's the first signature
-
@signature.is_default = true if current_user.signatures.empty?
-
-
if @signature.save
-
render json: {
-
data: signature_json(@signature, include_image: true),
-
message: "Firma creada exitosamente"
-
}, status: :created
-
else
-
render json: {
-
error: "Error al crear la firma",
-
errors: @signature.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# PATCH /api/v1/auth/signatures/:id
-
def update
-
if @signature.update(signature_params)
-
render json: {
-
data: signature_json(@signature, include_image: true),
-
message: "Firma actualizada exitosamente"
-
}
-
else
-
render json: {
-
error: "Error al actualizar la firma",
-
errors: @signature.errors.full_messages
-
}, status: :unprocessable_content
-
end
-
end
-
-
# DELETE /api/v1/auth/signatures/:id
-
def destroy
-
if @signature.in_use?
-
return render json: {
-
error: "Esta firma está siendo utilizada en documentos",
-
in_use: true,
-
documents_count: @signature.documents_using_count,
-
message: "No se puede eliminar. Use la opción de desactivar en su lugar."
-
}, status: :unprocessable_content
-
end
-
-
@signature.destroy
-
-
render json: {
-
message: "Firma eliminada exitosamente"
-
}
-
end
-
-
# POST /api/v1/auth/signatures/:id/toggle_active
-
def toggle_active
-
if @signature.active?
-
@signature.disable!
-
message = "Firma desactivada exitosamente"
-
else
-
@signature.enable!
-
message = "Firma activada exitosamente"
-
end
-
-
render json: {
-
data: signature_json(@signature, include_image: true),
-
message: message
-
}
-
end
-
-
# POST /api/v1/auth/signatures/:id/set_default
-
def set_default
-
@signature.set_as_default!
-
-
render json: {
-
data: signature_json(@signature),
-
message: "Firma establecida como predeterminada"
-
}
-
end
-
-
# GET /api/v1/auth/signatures/fonts
-
def fonts
-
render json: {
-
data: Identity::UserSignature::SIGNATURE_FONTS.map do |font|
-
{
-
name: font,
-
css_family: font.gsub(" ", "+"),
-
google_font_url: "https://fonts.googleapis.com/css2?family=#{font.gsub(' ', '+')}&display=swap"
-
}
-
end
-
}
-
end
-
-
private
-
-
def set_signature
-
@signature = current_user.signatures.find_by(uuid: params[:id])
-
-
return if @signature
-
-
render json: { error: "Firma no encontrada" }, status: :not_found
-
end
-
-
def signature_params
-
params.require(:signature).permit(
-
:name,
-
:signature_type,
-
:image_data,
-
:styled_text,
-
:font_family,
-
:font_color,
-
:font_size,
-
:is_default
-
)
-
end
-
-
def signature_json(signature, include_image: false)
-
json = {
-
id: signature.uuid,
-
name: signature.name,
-
signature_type: signature.signature_type,
-
is_default: signature.is_default,
-
active: signature.active?,
-
in_use: signature.in_use?,
-
documents_count: signature.documents_using_count,
-
created_at: signature.created_at.iso8601
-
}
-
-
if signature.styled?
-
json[:styled_text] = signature.styled_text
-
json[:font_family] = signature.font_family
-
json[:font_color] = signature.font_color
-
json[:font_size] = signature.font_size
-
end
-
-
if include_image
-
json[:image_data] = signature.to_image_data
-
end
-
-
json
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
class BaseController < ApplicationController
-
include Pundit::Authorization
-
-
before_action :authenticate_user!
-
-
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
-
-
protected
-
-
def current_user
-
@current_user ||= warden.authenticate(scope: :identity_user)
-
end
-
-
def current_organization
-
@current_organization ||= current_user&.organization
-
end
-
-
def authenticate_user!
-
return if current_user
-
-
render json: {
-
error: "Unauthorized",
-
message: "You need to sign in or sign up before continuing."
-
}, status: :unauthorized
-
end
-
-
def pundit_user
-
current_user
-
end
-
-
# Simple pagination without external gem
-
def paginate(scope)
-
page = (params[:page] || 1).to_i
-
per_page = [(params[:per_page] || 20).to_i, 100].min
-
-
@pagination_page = page
-
@pagination_per_page = per_page
-
@pagination_total = scope.count
-
-
scope.skip((page - 1) * per_page).limit(per_page)
-
end
-
-
def pagination_meta(_scope = nil)
-
{
-
current_page: @pagination_page,
-
per_page: @pagination_per_page,
-
total_count: @pagination_total,
-
total_pages: (@pagination_total.to_f / @pagination_per_page).ceil
-
}
-
end
-
-
def render_error(message, status: :bad_request, errors: [])
-
render json: { error: message, errors: Array(errors) }, status: status
-
end
-
-
private
-
-
def user_not_authorized
-
render json: { error: "You are not authorized to perform this action" }, status: :forbidden
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Content
-
class DocumentsController < BaseController
-
before_action :set_document, only: [:show, :update, :destroy, :lock, :unlock]
-
-
rescue_from ::Content::Document::ConcurrencyError, with: :handle_concurrency_error
-
rescue_from ::Content::Document::DocumentLockedError, with: :handle_locked_error
-
-
def index
-
authorize ::Content::Document
-
documents = policy_scope(::Content::Document)
-
-
documents = apply_filters(documents)
-
-
render json: {
-
data: documents.map { |d| document_response(d) }
-
}, status: :ok
-
end
-
-
def show
-
authorize @document
-
-
render json: {
-
data: document_response(@document, include_version: true)
-
}, status: :ok
-
end
-
-
def create
-
authorize ::Content::Document
-
-
document = ::Content::Document.new(document_params)
-
document.created_by = current_user
-
document.organization = current_user.organization
-
-
if document.save
-
# Create initial version if content provided
-
if version_params[:content].present? || version_params[:file_name].present?
-
document.create_version!(version_params)
-
end
-
-
render json: { data: document_response(document, include_version: true) }, status: :created
-
else
-
render_errors(document.errors.full_messages)
-
end
-
end
-
-
def update
-
authorize @document
-
-
@document.update_with_lock!(document_params.to_h)
-
-
render json: { data: document_response(@document) }, status: :ok
-
end
-
-
def destroy
-
authorize @document
-
-
@document.soft_delete!
-
-
render json: { message: "Document deleted successfully" }, status: :ok
-
end
-
-
def lock
-
authorize @document, :update?
-
-
if @document.lock!(current_user)
-
render json: { data: document_response(@document), message: "Document locked" }, status: :ok
-
else
-
render_error("Unable to lock document", status: :conflict)
-
end
-
end
-
-
def unlock
-
authorize @document, :update?
-
-
if @document.unlock!(current_user)
-
render json: { data: document_response(@document), message: "Document unlocked" }, status: :ok
-
else
-
render_error("Unable to unlock document", status: :conflict)
-
end
-
end
-
-
private
-
-
def set_document
-
@document = ::Content::Document.find(params[:id])
-
end
-
-
def document_params
-
params.require(:document).permit(
-
:title, :description, :status, :document_type, :folder_id,
-
tags: [], metadata: {}
-
)
-
end
-
-
def version_params
-
params.fetch(:version, {}).permit(
-
:file_name, :content, :content_type, :change_summary,
-
metadata: {}
-
)
-
end
-
-
def apply_filters(documents)
-
documents = documents.by_folder(params[:folder_id]) if params[:folder_id].present?
-
documents = documents.where(status: params[:status]) if params[:status].present?
-
documents = documents.by_type(params[:document_type]) if params[:document_type].present?
-
documents = documents.tagged_with(params[:tag]) if params[:tag].present?
-
documents
-
end
-
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
-
def document_response(document, include_version: false)
-
response = {
-
id: document.id.to_s,
-
uuid: document.uuid,
-
title: document.title,
-
description: document.description,
-
status: document.status,
-
document_type: document.document_type,
-
tags: document.tags,
-
folder_id: document.folder_id&.to_s,
-
organization_id: document.organization_id&.to_s,
-
current_version_number: document.current_version_number,
-
version_count: document.version_count,
-
locked: document.locked?,
-
locked_by_id: document.locked_by_id&.to_s,
-
locked_at: document.locked_at&.iso8601,
-
created_by_id: document.created_by_id&.to_s,
-
metadata: document.metadata,
-
created_at: document.created_at.iso8601,
-
updated_at: document.updated_at.iso8601
-
}
-
-
if include_version && document.current_version
-
response[:current_version] = version_response(document.current_version)
-
end
-
-
response
-
end
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
-
-
def version_response(version)
-
{
-
id: version.id.to_s,
-
uuid: version.uuid,
-
version_number: version.version_number,
-
file_name: version.file_name,
-
file_size: version.file_size,
-
content_type: version.content_type,
-
checksum: version.checksum,
-
change_summary: version.change_summary,
-
created_by_id: version.created_by_id&.to_s,
-
created_at: version.created_at.iso8601
-
}
-
end
-
-
def handle_concurrency_error(exception)
-
render_error(exception.message, status: :conflict)
-
end
-
-
def handle_locked_error(exception)
-
render_error(exception.message, status: :locked)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Content
-
class FoldersController < BaseController
-
before_action :set_folder, only: [:show, :update, :destroy]
-
-
def index
-
authorize ::Content::Folder
-
folders = policy_scope(::Content::Folder)
-
-
if params[:parent_id]
-
folders = folders.by_parent(params[:parent_id])
-
elsif params[:root]
-
folders = folders.root_folders
-
end
-
-
folders = folders.alphabetical
-
-
render json: {
-
data: folders.map { |f| folder_response(f) }
-
}, status: :ok
-
end
-
-
def show
-
authorize @folder
-
-
render json: {
-
data: folder_response(@folder, include_children: true)
-
}, status: :ok
-
end
-
-
def create
-
authorize ::Content::Folder
-
folder = ::Content::Folder.new(folder_params)
-
folder.created_by = current_user
-
folder.organization = current_user.organization
-
-
if folder.save
-
render json: { data: folder_response(folder) }, status: :created
-
else
-
render_errors(folder.errors.full_messages)
-
end
-
end
-
-
def update
-
authorize @folder
-
-
if @folder.update(folder_params)
-
render json: { data: folder_response(@folder) }, status: :ok
-
else
-
render_errors(@folder.errors.full_messages)
-
end
-
end
-
-
def destroy
-
authorize @folder
-
-
if @folder.children.any? || @folder.documents.any?
-
render_error("Cannot delete folder with contents", status: :unprocessable_entity)
-
else
-
@folder.soft_delete!
-
render json: { message: "Folder deleted successfully" }, status: :ok
-
end
-
end
-
-
private
-
-
def set_folder
-
@folder = ::Content::Folder.find(params[:id])
-
end
-
-
def folder_params
-
params.require(:folder).permit(:name, :description, :parent_id, metadata: {})
-
end
-
-
# rubocop:disable Metrics/AbcSize
-
def folder_response(folder, include_children: false)
-
response = {
-
id: folder.id.to_s,
-
uuid: folder.uuid,
-
name: folder.name,
-
description: folder.description,
-
path: folder.path,
-
depth: folder.depth,
-
parent_id: folder.parent_id&.to_s,
-
organization_id: folder.organization_id&.to_s,
-
document_count: folder.document_count,
-
metadata: folder.metadata,
-
created_at: folder.created_at.iso8601,
-
updated_at: folder.updated_at.iso8601
-
}
-
-
if include_children
-
response[:children] = folder.children.alphabetical.map { |c| folder_response(c) }
-
response[:ancestors] = folder.ancestors.map { |a| { id: a.id.to_s, name: a.name, path: a.path } }
-
end
-
-
response
-
end
-
# rubocop:enable Metrics/AbcSize
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Content
-
class VersionsController < BaseController
-
before_action :set_document
-
before_action :set_version, only: [:show]
-
-
rescue_from ::Content::Document::ConcurrencyError, with: :handle_concurrency_error
-
rescue_from ::Content::Document::DocumentLockedError, with: :handle_locked_error
-
-
def index
-
authorize @document, :show?
-
-
versions = @document.version_history
-
-
render json: {
-
data: versions.map { |v| version_response(v) }
-
}, status: :ok
-
end
-
-
def show
-
authorize @document, :show?
-
-
render json: {
-
data: version_response(@version, include_content: params[:include_content] == "true")
-
}, status: :ok
-
end
-
-
def create
-
authorize @document, :update?
-
-
version = @document.create_version!(version_params)
-
-
render json: {
-
data: version_response(version),
-
message: "Version #{version.version_number} created successfully"
-
}, status: :created
-
end
-
-
def current
-
authorize @document, :show?
-
-
version = @document.current_version
-
-
if version
-
render json: { data: version_response(version, include_content: true) }, status: :ok
-
else
-
render_error("No versions found", status: :not_found)
-
end
-
end
-
-
private
-
-
def set_document
-
@document = ::Content::Document.find(params[:document_id])
-
end
-
-
def set_version
-
@version = if params[:id] == "current"
-
@document.current_version
-
else
-
@document.versions.find(params[:id])
-
end
-
-
raise Mongoid::Errors::DocumentNotFound.new(::Content::DocumentVersion, params[:id]) unless @version
-
end
-
-
def version_params
-
permitted = params.require(:version).permit(
-
:file_name, :content, :content_type, :file_size, :change_summary,
-
metadata: {}
-
)
-
permitted[:created_by] = current_user
-
permitted
-
end
-
-
def version_response(version, include_content: false)
-
response = {
-
id: version.id.to_s,
-
uuid: version.uuid,
-
document_id: version.document_id.to_s,
-
version_number: version.version_number,
-
file_name: version.file_name,
-
file_size: version.file_size,
-
content_type: version.content_type,
-
checksum: version.checksum,
-
change_summary: version.change_summary,
-
is_latest: version.latest?,
-
created_by_id: version.created_by_id&.to_s,
-
metadata: version.metadata,
-
created_at: version.created_at.iso8601
-
}
-
-
response[:content] = version.content if include_content
-
-
response
-
end
-
-
def handle_concurrency_error(exception)
-
render_error(exception.message, status: :conflict)
-
end
-
-
def handle_locked_error(exception)
-
render_error(exception.message, status: :locked)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
class DocumentsController < BaseController
-
before_action :set_document, only: [:show, :download, :preview, :destroy, :sign]
-
-
# GET /api/v1/documents
-
def index
-
page = (params[:page] || 1).to_i
-
per_page = (params[:per_page] || 20).to_i
-
skip_count = (page - 1) * per_page
-
-
base_scope = policy_scope(::Templates::GeneratedDocument).order(created_at: :desc)
-
-
# Apply filters
-
base_scope = apply_document_filters(base_scope)
-
-
total_count = base_scope.count
-
documents = base_scope.skip(skip_count).limit(per_page).to_a
-
-
total_pages = (total_count.to_f / per_page).ceil
-
-
render json: {
-
data: documents.map { |doc| document_json(doc) },
-
meta: {
-
current_page: page,
-
total_pages: total_pages,
-
total_count: total_count,
-
per_page: per_page
-
}
-
}
-
end
-
-
# GET /api/v1/documents/:id
-
def show
-
authorize @document
-
-
render json: {
-
data: document_json(@document, detailed: true)
-
}
-
end
-
-
# GET /api/v1/documents/:id/download
-
def download
-
authorize @document
-
-
file_content = @document.file_content
-
-
if file_content
-
send_data file_content,
-
filename: @document.file_name || "#{@document.name}.pdf",
-
type: "application/pdf",
-
disposition: "attachment"
-
else
-
render json: { error: "El archivo no está disponible" }, status: :not_found
-
end
-
end
-
-
# GET /api/v1/documents/:id/preview
-
def preview
-
authorize @document
-
-
file_content = @document.file_content
-
-
if file_content
-
send_data file_content,
-
filename: @document.file_name || "#{@document.name}.pdf",
-
type: "application/pdf",
-
disposition: "inline"
-
else
-
render json: { error: "El archivo no está disponible" }, status: :not_found
-
end
-
end
-
-
# DELETE /api/v1/documents/:id
-
def destroy
-
authorize @document
-
-
# Delete associated files from GridFS
-
if @document.draft_file_id
-
Mongoid::GridFs.delete(@document.draft_file_id) rescue nil
-
end
-
if @document.final_file_id
-
Mongoid::GridFs.delete(@document.final_file_id) rescue nil
-
end
-
-
@document.destroy!
-
-
render json: { message: "Documento eliminado exitosamente" }
-
end
-
-
# GET /api/v1/documents/pending_signatures
-
# Returns documents pending signature by the current user
-
def pending_signatures
-
documents = ::Templates::GeneratedDocument
-
.where(organization_id: current_organization.id)
-
.pending_signature_by(current_user)
-
.order(created_at: :desc)
-
-
render json: {
-
data: documents.map { |doc| document_json(doc, detailed: true) },
-
meta: {
-
total: documents.count
-
}
-
}
-
end
-
-
# POST /api/v1/documents/:id/sign
-
# Signs the document with the current user's digital signature
-
def sign
-
authorize @document
-
-
# Check if user can sign this document
-
unless @document.can_be_signed_by?(current_user)
-
return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
-
end
-
-
# Get user's default signature
-
signature = current_user.signatures.active.default_signature.first || current_user.signatures.active.first
-
unless signature
-
return render json: { error: "No tienes una firma digital configurada. Configura tu firma en tu perfil." }, status: :unprocessable_entity
-
end
-
-
@document.sign!(user: current_user, signature: signature)
-
-
render json: {
-
data: document_json(@document, detailed: true),
-
message: "Documento firmado exitosamente",
-
all_signed: @document.all_required_signed?
-
}
-
rescue ::Templates::GeneratedDocument::SignatureError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
private
-
-
def set_document
-
return render json: { error: "ID de documento requerido" }, status: :bad_request if params[:id].blank?
-
-
@document = ::Templates::GeneratedDocument.where(uuid: params[:id]).first
-
return if @document
-
-
render json: { error: "Documento no encontrado" }, status: :not_found
-
end
-
-
def apply_document_filters(scope)
-
# Filter by category (can be comma-separated for multiple categories)
-
if params[:category].present?
-
categories = params[:category].split(",").map(&:strip)
-
template_ids = ::Templates::Template.where(:category.in => categories).pluck(:id)
-
scope = scope.where(:template_id.in => template_ids)
-
end
-
-
# Filter by status
-
if params[:status].present?
-
scope = scope.where(status: params[:status])
-
end
-
-
# Filter by search query
-
if params[:q].present?
-
query = /#{Regexp.escape(params[:q])}/i
-
scope = scope.or({ name: query })
-
end
-
-
# Filter by employee_id (accepts UUID or MongoDB ObjectId)
-
if params[:employee_id].present?
-
employee = ::Hr::Employee.find_by(uuid: params[:employee_id]) ||
-
::Hr::Employee.where(id: params[:employee_id]).first
-
scope = scope.where(employee_id: employee&.id) if employee
-
end
-
-
# Filter by module (hr or legal)
-
if params[:module].present?
-
case params[:module]
-
when "hr"
-
# HR documents: includes employee contracts (with employee_id) and other HR categories
-
hr_categories = %w[vacation certification employee_contract employee contract]
-
template_ids = ::Templates::Template.where(:category.in => hr_categories).pluck(:id)
-
# Filter by template categories, but for 'contract' only include those with employee_id
-
contract_template_ids = ::Templates::Template.where(category: "contract").pluck(:id)
-
other_template_ids = template_ids - contract_template_ids
-
scope = scope.where(
-
"$or" => [
-
{ :template_id.in => other_template_ids },
-
{ :template_id.in => contract_template_ids, :employee_id.ne => nil }
-
]
-
)
-
when "legal"
-
# Legal documents: contracts without employee_id (third party contracts)
-
legal_categories = %w[contract legal]
-
template_ids = ::Templates::Template.where(:category.in => legal_categories).pluck(:id)
-
scope = scope.where(:template_id.in => template_ids, :employee_id => nil)
-
end
-
end
-
-
scope
-
end
-
-
def document_json(document, detailed: false)
-
employee = document.employee
-
template = document.template
-
-
json = {
-
id: document.uuid,
-
name: document.name,
-
file_name: document.file_name,
-
status: document.status,
-
template_name: template&.name,
-
template_category: template&.category,
-
employee_name: employee&.full_name,
-
employee_number: employee&.employee_number,
-
created_at: document.created_at.iso8601,
-
requested_by: document.requested_by&.full_name,
-
can_sign: document.can_be_signed_by?(current_user),
-
has_pending_signature: document.signatures.any? { |s| s["user_id"] == current_user.id.to_s && s["status"] == "pending" },
-
user_has_digital_signature: current_user.signatures.active.exists?
-
}
-
-
if detailed
-
json.merge!(
-
variable_values: document.variable_values,
-
sequential_signing: document.sequential_signing?,
-
signatures: document.signatures.map do |sig|
-
order_status = document.signature_with_order_status(sig)
-
{
-
signatory_label: sig["signatory_label"],
-
signatory_type_code: sig["signatory_type_code"],
-
user_name: sig["user_name"],
-
user_id: sig["user_id"],
-
status: sig["status"],
-
required: sig["required"],
-
signed_at: sig["signed_at"],
-
signed_by_name: sig["signed_by_name"],
-
can_sign_now: order_status[:can_sign_now],
-
waiting_for: order_status[:waiting_for]
-
}
-
end,
-
pending_signatures_count: document.pending_signatures_count,
-
completed_signatures_count: document.completed_signatures_count,
-
total_required_signatures: document.total_required_signatures,
-
all_signed: document.all_required_signed?,
-
next_signatory: document.next_signatory_to_sign&.dig("signatory_label"),
-
completed_at: document.completed_at&.iso8601,
-
can_download: document.draft_file_id.present?
-
)
-
end
-
-
json
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
class FoldersController < BaseController
-
before_action :set_folder, only: [:show, :update, :destroy, :add_document, :remove_document]
-
-
# GET /api/v1/folders
-
def index
-
folders = ::Documents::Folder
-
.for_organization(current_organization)
-
.ordered
-
-
# Filter by parent
-
if params[:parent_id].present?
-
if params[:parent_id] == "root"
-
folders = folders.root_folders
-
else
-
parent = ::Documents::Folder.find_by(uuid: params[:parent_id])
-
folders = folders.where(parent_id: parent&.id)
-
end
-
else
-
folders = folders.root_folders
-
end
-
-
render json: {
-
data: folders.map { |f| folder_json(f) },
-
meta: { total: folders.count }
-
}
-
end
-
-
# GET /api/v1/folders/:id
-
def show
-
render json: {
-
data: folder_json(@folder, detailed: true)
-
}
-
end
-
-
# POST /api/v1/folders
-
def create
-
folder = ::Documents::Folder.new(folder_params)
-
folder.organization = current_organization
-
folder.created_by = current_user
-
-
# Handle parent folder
-
if params[:folder][:parent_id].present?
-
parent = ::Documents::Folder.find_by(uuid: params[:folder][:parent_id])
-
folder.parent = parent
-
end
-
-
if folder.save
-
render json: {
-
data: folder_json(folder),
-
message: "Carpeta creada exitosamente"
-
}, status: :created
-
else
-
render json: { error: folder.errors.full_messages.join(", ") }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /api/v1/folders/:id
-
def update
-
if @folder.is_system
-
return render json: { error: "No se puede modificar una carpeta del sistema" }, status: :forbidden
-
end
-
-
if @folder.update(folder_params)
-
render json: {
-
data: folder_json(@folder),
-
message: "Carpeta actualizada exitosamente"
-
}
-
else
-
render json: { error: @folder.errors.full_messages.join(", ") }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/folders/:id
-
def destroy
-
if @folder.is_system
-
return render json: { error: "No se puede eliminar una carpeta del sistema" }, status: :forbidden
-
end
-
-
if @folder.subfolders.any?
-
return render json: { error: "No se puede eliminar una carpeta que contiene subcarpetas" }, status: :conflict
-
end
-
-
@folder.destroy!
-
render json: { message: "Carpeta eliminada exitosamente" }
-
end
-
-
# POST /api/v1/folders/:id/documents
-
def add_document
-
document = ::Templates::GeneratedDocument.find_by(uuid: params[:document_id])
-
-
unless document
-
return render json: { error: "Documento no encontrado" }, status: :not_found
-
end
-
-
folder_doc = @folder.folder_documents.new(
-
document: document,
-
added_by: current_user
-
)
-
-
if folder_doc.save
-
render json: {
-
data: folder_json(@folder),
-
message: "Documento agregado a la carpeta"
-
}
-
else
-
render json: { error: folder_doc.errors.full_messages.join(", ") }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/folders/:id/documents/:document_id
-
def remove_document
-
document = ::Templates::GeneratedDocument.find_by(uuid: params[:document_id])
-
-
unless document
-
return render json: { error: "Documento no encontrado" }, status: :not_found
-
end
-
-
folder_doc = @folder.folder_documents.find_by(document_id: document.id)
-
-
unless folder_doc
-
return render json: { error: "El documento no está en esta carpeta" }, status: :not_found
-
end
-
-
folder_doc.destroy!
-
render json: {
-
data: folder_json(@folder),
-
message: "Documento removido de la carpeta"
-
}
-
end
-
-
private
-
-
def set_folder
-
@folder = ::Documents::Folder.find_by(
-
uuid: params[:id],
-
organization_id: current_organization.id
-
)
-
-
unless @folder
-
render json: { error: "Carpeta no encontrada" }, status: :not_found
-
end
-
end
-
-
def folder_params
-
params.require(:folder).permit(:name, :description, :color, :icon)
-
end
-
-
def folder_json(folder, detailed: false)
-
json = {
-
id: folder.uuid,
-
name: folder.name,
-
description: folder.description,
-
color: folder.color,
-
icon: folder.icon,
-
is_system: folder.is_system,
-
documents_count: folder.documents_count,
-
subfolders_count: folder.subfolders.count,
-
parent_id: folder.parent&.uuid,
-
created_at: folder.created_at.iso8601
-
}
-
-
if detailed
-
json[:full_path] = folder.full_path
-
json[:ancestors] = folder.ancestors.map { |a| { id: a.uuid, name: a.name } }
-
json[:subfolders] = folder.subfolders.ordered.map { |s| folder_json(s) }
-
json[:documents] = folder.folder_documents.includes(:document).map do |fd|
-
doc = fd.document
-
next unless doc
-
{
-
id: doc.uuid,
-
name: doc.name,
-
status: doc.status,
-
created_at: doc.created_at.iso8601,
-
added_at: fd.created_at.iso8601
-
}
-
end.compact
-
end
-
-
json
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
class HealthController < ApplicationController
-
skip_before_action :set_request_context, only: [:show]
-
-
def show
-
result = HealthCheckService.call
-
-
if result.success?
-
render json: {
-
status: result.result[:status],
-
checks: result.result[:checks],
-
timestamp: result.result[:timestamp],
-
version: result.result[:version]
-
}, status: result.result[:status] == "healthy" ? :ok : :service_unavailable
-
else
-
render json: {
-
status: "error",
-
message: result.errors.first
-
}, status: :internal_server_error
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Hr
-
# Approvals management for supervisors and HR staff
-
# rubocop:disable Metrics/ClassLength
-
class ApprovalsController < BaseController
-
before_action :ensure_approver_access
-
before_action :set_approvable, only: [:show, :approve, :reject]
-
-
# GET /api/v1/hr/approvals
-
# Params: status=pending (default) or status=history
-
def index
-
if params[:status] == "history"
-
@approvals = fetch_history_approvals
-
render json: {
-
data: {
-
vacation_requests: @approvals[:vacations].map { |v| vacation_json(v) },
-
certification_requests: @approvals[:certifications].map { |c| certification_json(c) }
-
},
-
meta: {
-
total: @approvals[:vacations].count + @approvals[:certifications].count
-
}
-
}
-
else
-
@approvals = fetch_pending_approvals
-
render json: {
-
data: {
-
vacation_requests: @approvals[:vacations].map { |v| vacation_json(v) },
-
certification_requests: @approvals[:certifications].map { |c| certification_json(c) }
-
},
-
meta: {
-
total_pending: @approvals[:vacations].count + @approvals[:certifications].count
-
}
-
}
-
end
-
end
-
-
# GET /api/v1/hr/approvals/:id
-
def show
-
render json: { data: approvable_json(@approvable, detailed: true) }
-
end
-
-
# POST /api/v1/hr/approvals/:id/approve
-
def approve
-
case @approvable
-
when ::Hr::VacationRequest
-
@approvable.approve!(actor: current_employee, reason: params[:reason])
-
when ::Hr::EmploymentCertificationRequest
-
# Certifications need to go through processing first
-
@approvable.start_processing!(actor: current_employee) if @approvable.pending?
-
@approvable.complete!(actor: current_employee, document_uuid: params[:document_uuid] || SecureRandom.uuid)
-
end
-
-
render json: {
-
data: approvable_json(@approvable),
-
message: "Request approved successfully"
-
}
-
rescue StandardError => e
-
handle_approval_error(e)
-
end
-
-
# POST /api/v1/hr/approvals/:id/reject
-
def reject
-
return render_missing_reason if params[:reason].blank?
-
-
@approvable.reject!(actor: current_employee, reason: params[:reason])
-
-
render json: {
-
data: approvable_json(@approvable),
-
message: "Request rejected"
-
}
-
rescue StandardError => e
-
handle_approval_error(e)
-
end
-
-
private
-
-
def ensure_approver_access
-
return if current_employee.hr_staff? || current_employee.hr_manager? || current_employee.supervisor?
-
-
render json: { error: "Access denied. Approver privileges required." }, status: :forbidden
-
end
-
-
def set_approvable
-
@approvable = find_approvable(params[:id])
-
-
return if @approvable
-
-
render json: { error: "Request not found" }, status: :not_found
-
end
-
-
def find_approvable(uuid)
-
::Hr::VacationRequest.where(uuid: uuid).first ||
-
::Hr::EmploymentCertificationRequest.where(uuid: uuid).first
-
end
-
-
def fetch_pending_approvals
-
{
-
vacations: pending_vacations_scope,
-
certifications: pending_certifications_scope
-
}
-
end
-
-
def fetch_history_approvals
-
{
-
vacations: history_vacations_scope,
-
certifications: history_certifications_scope
-
}
-
end
-
-
def pending_vacations_scope
-
base_scope(::Hr::VacationRequest).pending.order(submitted_at: :asc)
-
end
-
-
def pending_certifications_scope
-
base_scope(::Hr::EmploymentCertificationRequest).pending.order(submitted_at: :asc)
-
end
-
-
def history_vacations_scope
-
base_scope(::Hr::VacationRequest)
-
.where(:status.in => %w[approved rejected cancelled])
-
.order(decided_at: :desc)
-
.limit(50)
-
end
-
-
def history_certifications_scope
-
base_scope(::Hr::EmploymentCertificationRequest)
-
.where(:status.in => %w[completed rejected cancelled])
-
.order(completed_at: :desc)
-
.limit(50)
-
end
-
-
def base_scope(klass)
-
if current_employee.hr_staff? || current_employee.hr_manager?
-
klass.where(organization_id: current_organization.id)
-
else
-
klass.where(:employee_id.in => current_employee.subordinates.pluck(:id))
-
end
-
end
-
-
def approvable_json(record, detailed: false)
-
case record
-
when ::Hr::VacationRequest
-
vacation_json(record, detailed: detailed)
-
when ::Hr::EmploymentCertificationRequest
-
certification_json(record, detailed: detailed)
-
end
-
end
-
-
def vacation_json(vacation, detailed: false)
-
json = {
-
id: vacation.uuid,
-
type: "vacation_request",
-
request_number: vacation.request_number,
-
vacation_type: vacation.vacation_type,
-
start_date: vacation.start_date&.iso8601,
-
end_date: vacation.end_date&.iso8601,
-
days_requested: vacation.days_requested,
-
status: vacation.status,
-
submitted_at: vacation.submitted_at&.iso8601,
-
employee: employee_summary(vacation.employee)
-
}
-
-
if detailed
-
json.merge!(
-
reason: vacation.reason,
-
notes: vacation.notes,
-
history: vacation.history
-
)
-
end
-
-
json
-
end
-
-
def certification_json(certification, detailed: false)
-
json = {
-
id: certification.uuid,
-
type: "certification_request",
-
request_number: certification.request_number,
-
certification_type: certification.certification_type,
-
purpose: certification.purpose,
-
status: certification.status,
-
estimated_days: certification.estimated_days,
-
submitted_at: certification.submitted_at&.iso8601,
-
employee: employee_summary(certification.employee)
-
}
-
-
if detailed
-
json.merge!(
-
language: certification.language,
-
include_salary: certification.include_salary,
-
include_position: certification.include_position,
-
additional_info: certification.additional_info
-
)
-
end
-
-
json
-
end
-
-
def employee_summary(employee)
-
return nil unless employee
-
-
{
-
id: employee.uuid,
-
name: employee.full_name,
-
department: employee.department,
-
job_title: employee.job_title,
-
available_vacation_days: employee.available_vacation_days
-
}
-
end
-
-
def current_employee
-
@current_employee ||= ::Hr::Employee.for_user(current_user) ||
-
::Hr::Employee.create!(
-
user: current_user,
-
organization: current_organization,
-
job_title: current_user.title,
-
department: current_user.department,
-
hire_date: Date.current,
-
vacation_balance_days: 15.0
-
)
-
end
-
-
def render_missing_reason
-
render json: { error: "Rejection reason is required" }, status: :unprocessable_content
-
end
-
-
def handle_approval_error(error)
-
Rails.logger.error "Approval error: #{error.class} - #{error.message}"
-
Rails.logger.error error.backtrace.first(10).join("\n")
-
-
status = error_status_for(error)
-
-
if status
-
render json: { error: error.message }, status: status
-
else
-
render json: { error: "Error processing approval: #{error.message}" }, status: :internal_server_error
-
end
-
end
-
-
def error_status_for(error)
-
case error
-
when ::Hr::VacationRequest::InvalidStateError,
-
::Hr::EmploymentCertificationRequest::InvalidStateError,
-
::Hr::VacationRequest::ValidationError,
-
::Hr::EmploymentCertificationRequest::ValidationError
-
:unprocessable_content
-
when ::Hr::VacationRequest::AuthorizationError,
-
::Hr::EmploymentCertificationRequest::AuthorizationError
-
:forbidden
-
end
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Hr
-
# Employment certification requests management
-
class CertificationsController < BaseController
-
before_action :set_certification, only: [:show, :update, :destroy, :cancel, :generate_document, :download_document, :sign_document]
-
-
# GET /api/v1/hr/certifications
-
def index
-
@certifications = policy_scope(::Hr::EmploymentCertificationRequest)
-
.order(created_at: :desc)
-
-
@certifications = apply_filters(@certifications)
-
@certifications = paginate(@certifications)
-
-
render json: {
-
data: @certifications.map { |c| certification_json(c) },
-
meta: pagination_meta(@certifications)
-
}
-
end
-
-
# GET /api/v1/hr/certifications/:id
-
def show
-
authorize @certification
-
-
render json: { data: certification_json(@certification, detailed: true) }
-
end
-
-
# GET /api/v1/hr/certifications/available_types
-
# Returns only certification types that have active templates
-
def available_types
-
available = fetch_available_certification_types
-
render json: { data: available }
-
end
-
-
# POST /api/v1/hr/certifications
-
def create
-
@certification = ::Hr::EmploymentCertificationRequest.new(certification_params)
-
@certification.employee = current_employee
-
@certification.organization = current_organization
-
-
authorize @certification
-
-
if @certification.save
-
render json: { data: certification_json(@certification) }, status: :created
-
else
-
render json: { errors: @certification.errors.full_messages }, status: :unprocessable_content
-
end
-
end
-
-
# PATCH /api/v1/hr/certifications/:id
-
def update
-
authorize @certification
-
-
unless @certification.pending?
-
return render json: { error: "Can only update pending requests" }, status: :unprocessable_content
-
end
-
-
if @certification.update(certification_params)
-
render json: { data: certification_json(@certification) }
-
else
-
render json: { errors: @certification.errors.full_messages }, status: :unprocessable_content
-
end
-
end
-
-
# POST /api/v1/hr/certifications/:id/cancel
-
def cancel
-
authorize @certification, :cancel?
-
-
@certification.cancel!(actor: current_employee)
-
-
render json: {
-
data: certification_json(@certification),
-
message: "Certification request cancelled"
-
}
-
rescue ::Hr::EmploymentCertificationRequest::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_content
-
rescue ::Hr::EmploymentCertificationRequest::AuthorizationError => e
-
render json: { error: e.message }, status: :forbidden
-
end
-
-
# DELETE /api/v1/hr/certifications/:id
-
def destroy
-
authorize @certification, :destroy?
-
-
@certification.destroy!
-
-
render json: { message: "Certificación eliminada exitosamente" }
-
end
-
-
# POST /api/v1/hr/certifications/:id/generate_document
-
def generate_document
-
authorize @certification, :generate_document?
-
-
# Find appropriate template
-
template = find_template_for_certification
-
unless template
-
return render json: {
-
error: "No hay template activo para este tipo de certificación"
-
}, status: :unprocessable_content
-
end
-
-
# Build context for variable resolution
-
context = {
-
employee: @certification.employee,
-
organization: current_organization,
-
request: @certification,
-
user: current_user
-
}
-
-
# Generate document using robust service for better formatting
-
generator = ::Templates::RobustDocumentGeneratorService.new(template, context)
-
generated_doc = generator.generate!
-
-
# Link document to certification
-
@certification.update!(
-
document_uuid: generated_doc.uuid,
-
status: "processing"
-
)
-
-
render json: {
-
data: {
-
certification: certification_json(@certification, detailed: true),
-
document: generated_document_json(generated_doc)
-
},
-
message: "Documento generado exitosamente"
-
}
-
rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
-
render json: {
-
error: e.message,
-
error_type: "missing_variables",
-
missing_data: e.missing_data,
-
action_required: build_action_required(e.missing_data)
-
}, status: :unprocessable_content
-
rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
-
render json: { error: e.message }, status: :unprocessable_content
-
end
-
-
# POST /api/v1/hr/certifications/:id/sign_document
-
def sign_document
-
authorize @certification, :sign_document?
-
-
unless @certification.document_uuid
-
return render json: { error: "No hay documento para firmar" }, status: :not_found
-
end
-
-
generated_doc = ::Templates::GeneratedDocument.where(uuid: @certification.document_uuid).first
-
unless generated_doc
-
return render json: { error: "Documento no encontrado" }, status: :not_found
-
end
-
-
# Verificar que el usuario puede firmar este documento
-
unless generated_doc.can_be_signed_by?(current_user)
-
# Verificar si es HR y hay firma pendiente de HR
-
# Check both signatory_role and signatory_type_code for HR signatures
-
pending_hr = generated_doc.signatures.find do |s|
-
s["status"] == "pending" && (
-
s["signatory_role"] == "hr" ||
-
s["signatory_type_code"] == "hr" ||
-
s["signatory_label"]&.downcase&.include?("recursos humanos")
-
)
-
end
-
if pending_hr && hr_or_admin?
-
# Asignar este usuario HR como firmante
-
pending_hr["user_id"] = current_user.id.to_s
-
pending_hr["user_name"] = current_user.full_name
-
generated_doc.save!
-
else
-
return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
-
end
-
end
-
-
# Obtener la firma digital del usuario
-
signature = ::Identity::UserSignature.where(user_id: current_user.id, is_default: true).first
-
signature ||= ::Identity::UserSignature.where(user_id: current_user.id).first
-
-
unless signature
-
return render json: {
-
error: "No tienes firma digital configurada",
-
action_required: {
-
type: "configure_signature",
-
label: "Configurar mi firma digital",
-
url: "/profile"
-
}
-
}, status: :unprocessable_content
-
end
-
-
# Aplicar la firma
-
generated_doc.sign!(user: current_user, signature: signature)
-
-
render json: {
-
message: "Documento firmado exitosamente",
-
document: {
-
uuid: generated_doc.uuid,
-
status: generated_doc.status,
-
pending_signatures: generated_doc.pending_signatories.map { |s| s["signatory_label"] },
-
completed_signatures: generated_doc.signed_signatories.map { |s| s["signatory_label"] },
-
all_signed: generated_doc.all_required_signed?
-
}
-
}
-
rescue ::Templates::GeneratedDocument::SignatureError => e
-
render json: { error: e.message }, status: :unprocessable_content
-
end
-
-
# GET /api/v1/hr/certifications/:id/download_document
-
def download_document
-
authorize @certification, :show?
-
-
unless @certification.document_uuid
-
return render json: { error: "No hay documento generado" }, status: :not_found
-
end
-
-
generated_doc = ::Templates::GeneratedDocument.where(uuid: @certification.document_uuid).first
-
unless generated_doc
-
return render json: { error: "Documento no encontrado" }, status: :not_found
-
end
-
-
# Empleados solo pueden descargar documentos con todas las firmas
-
unless can_download_document?(generated_doc)
-
pending = generated_doc.pending_signatories.map { |s| s["signatory_label"] }.join(", ")
-
return render json: {
-
error: "Documento pendiente de firmas",
-
message: "El documento requiere firmas de: #{pending}",
-
pending_signatures: generated_doc.pending_signatories,
-
completed_signatures: generated_doc.signed_signatories
-
}, status: :forbidden
-
end
-
-
file_content = generated_doc.file_content
-
unless file_content
-
return render json: { error: "Error al leer el archivo" }, status: :internal_server_error
-
end
-
-
send_data file_content,
-
filename: generated_doc.file_name || "certificacion.pdf",
-
type: "application/pdf",
-
disposition: "inline"
-
end
-
-
private
-
-
def set_certification
-
@certification = ::Hr::EmploymentCertificationRequest.find_by!(uuid: params[:id])
-
rescue Mongoid::Errors::DocumentNotFound
-
render json: { error: "Certification request not found" }, status: :not_found
-
end
-
-
def certification_params
-
params.require(:certification).permit(
-
:certification_type,
-
:purpose,
-
:purpose_details,
-
:language,
-
:delivery_method,
-
:addressee,
-
:include_salary,
-
:include_position,
-
:include_department,
-
:include_start_date,
-
:special_instructions
-
)
-
end
-
-
def apply_filters(scope)
-
scope = scope.where(status: params[:status]) if params[:status].present?
-
scope = scope.where(certification_type: params[:type]) if params[:type].present?
-
scope
-
end
-
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
-
def certification_json(certification, detailed: false)
-
# Get document info if exists
-
doc_info = nil
-
if certification.document_uuid
-
generated_doc = ::Templates::GeneratedDocument.where(uuid: certification.document_uuid).first
-
if generated_doc
-
doc_info = {
-
status: generated_doc.status,
-
can_download: can_download_document?(generated_doc),
-
pending_signatures: generated_doc.pending_signatories.map { |s| s["signatory_label"] },
-
completed_signatures: generated_doc.signed_signatories.map { |s| s["signatory_label"] },
-
all_signed: generated_doc.all_required_signed?
-
}
-
end
-
end
-
-
json = {
-
id: certification.uuid,
-
request_number: certification.request_number,
-
certification_type: certification.certification_type,
-
purpose: certification.purpose,
-
status: certification.status,
-
estimated_days: certification.estimated_days,
-
submitted_at: certification.submitted_at&.iso8601,
-
created_at: certification.created_at.iso8601,
-
document_uuid: certification.document_uuid,
-
document_info: doc_info
-
}
-
-
if detailed
-
json.merge!(
-
language: certification.language,
-
delivery_method: certification.delivery_method,
-
include_salary: certification.include_salary,
-
include_position: certification.include_position,
-
include_department: certification.include_department,
-
include_start_date: certification.include_start_date,
-
completed_at: certification.completed_at&.iso8601,
-
rejection_reason: certification.rejection_reason,
-
processed_by: certification.processed_by ? employee_summary(certification.processed_by) : nil
-
)
-
end
-
-
json
-
end
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
-
-
def employee_summary(employee)
-
{
-
id: employee.uuid,
-
name: employee.full_name,
-
email: employee.user&.email
-
}
-
end
-
-
def current_employee
-
@current_employee ||= ::Hr::Employee.for_user(current_user) ||
-
::Hr::Employee.create!(
-
user: current_user,
-
organization: current_organization,
-
job_title: current_user.title,
-
department: current_user.department,
-
hire_date: Date.current,
-
vacation_balance_days: 15.0
-
)
-
end
-
-
def find_template_for_certification
-
# Find active template for certification category that matches the certification type
-
::Templates::Template
-
.for_organization(current_organization)
-
.active
-
.where(category: "certification")
-
.where(certification_type: @certification.certification_type)
-
.first
-
end
-
-
# Returns certification types that have active templates
-
def fetch_available_certification_types
-
# All possible certification types
-
all_types = ::Hr::EmploymentCertificationRequest::CERTIFICATION_TYPES
-
-
# Find which types have active templates
-
active_templates = ::Templates::Template
-
.for_organization(current_organization)
-
.active
-
.where(category: "certification")
-
.where(:certification_type.in => all_types)
-
.pluck(:certification_type)
-
.uniq
-
-
# Map to type info
-
type_labels = {
-
"employment" => "Certificado de Empleo",
-
"salary" => "Certificado de Salario",
-
"position" => "Certificado de Cargo",
-
"full" => "Certificado Completo",
-
"custom" => "Certificado Personalizado"
-
}
-
-
type_descriptions = {
-
"employment" => "Verificación básica de empleo",
-
"salary" => "Incluye información salarial",
-
"position" => "Detalla cargo y responsabilidades",
-
"full" => "Información completa de empleo",
-
"custom" => "Contenido personalizado según necesidad"
-
}
-
-
active_templates.map do |type|
-
{
-
value: type,
-
label: type_labels[type] || type.humanize,
-
description: type_descriptions[type] || ""
-
}
-
end
-
end
-
-
def generated_document_json(doc)
-
{
-
id: doc.uuid,
-
name: doc.name,
-
status: doc.status,
-
file_name: doc.file_name,
-
pending_signatures: doc.pending_signatures_count,
-
total_signatures: doc.total_required_signatures,
-
created_at: doc.created_at.iso8601
-
}
-
end
-
-
def build_action_required(missing_data)
-
actions = []
-
-
if missing_data[:by_source]["employee"]&.any?
-
actions << {
-
type: "edit_employee",
-
label: "Completar datos del empleado",
-
employee_id: missing_data[:employee_id],
-
employee_name: missing_data[:employee_name],
-
fields: missing_data[:by_source]["employee"].map { |v| v[:field] }
-
}
-
end
-
-
if missing_data[:by_source]["organization"]&.any?
-
actions << {
-
type: "edit_organization",
-
label: "Completar datos de la organización",
-
fields: missing_data[:by_source]["organization"].map { |v| v[:field] }
-
}
-
end
-
-
if missing_data[:by_source][nil]&.any?
-
actions << {
-
type: "configure_mappings",
-
label: "Configurar mapeo de variables",
-
variables: missing_data[:by_source][nil].map { |v| v[:variable] }
-
}
-
end
-
-
actions
-
end
-
-
# HR/Admin pueden descargar documentos sin firmas completas
-
# Empleados solo pueden descargar documentos completamente firmados
-
def can_download_document?(generated_doc)
-
return true if hr_or_admin?
-
return true if generated_doc.completed?
-
return true if generated_doc.signatures.empty? # Sin requisito de firmas
-
-
false
-
end
-
-
def hr_or_admin?
-
current_user.has_role?(:admin) ||
-
current_user.has_role?(:hr) ||
-
current_user.has_role?(:hr_manager)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Hr
-
# HR Dashboard with statistics
-
class DashboardController < BaseController
-
before_action :ensure_hr_access
-
-
# GET /api/v1/hr/dashboard
-
def show
-
stats = ::Hr::HrService.new(
-
organization: current_organization,
-
actor: current_employee
-
).statistics
-
-
render json: { data: stats }
-
rescue ::Hr::HrService::AuthorizationError => e
-
render json: { error: e.message }, status: :forbidden
-
end
-
-
private
-
-
def ensure_hr_access
-
return if current_employee.hr_staff? || current_employee.hr_manager?
-
-
render json: { error: "HR access required" }, status: :forbidden
-
end
-
-
def current_employee
-
@current_employee ||= ::Hr::Employee.find_or_create_for_user!(current_user)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Hr
-
# Employee information (read-only for most users)
-
class EmployeesController < BaseController
-
before_action :set_employee, only: [:show, :update, :subordinates, :vacation_balance, :create_account, :generate_document]
-
-
# GET /api/v1/hr/employees
-
def index
-
authorize ::Hr::Employee
-
-
@employees = policy_scope(::Hr::Employee)
-
.where(organization_id: current_organization.id)
-
.active
-
-
@employees = apply_filters(@employees)
-
@employees = apply_sorting(@employees)
-
@employees = paginate(@employees)
-
-
render json: {
-
data: @employees.map { |e| employee_json(e) },
-
meta: pagination_meta(@employees)
-
}
-
end
-
-
# GET /api/v1/hr/employees/:id
-
def show
-
authorize @employee
-
-
render json: { data: employee_json(@employee, detailed: true) }
-
end
-
-
# PATCH /api/v1/hr/employees/:id
-
def update
-
authorize @employee
-
-
if @employee.update(employee_params)
-
render json: { data: employee_json(@employee, detailed: true) }
-
else
-
render json: { error: @employee.errors.full_messages.join(", ") }, status: :unprocessable_content
-
end
-
end
-
-
# GET /api/v1/hr/employees/:id/subordinates
-
def subordinates
-
authorize @employee, :show?
-
-
subs = @employee.subordinates.active.order(last_name: :asc)
-
-
render json: {
-
data: subs.map { |e| employee_json(e) },
-
meta: { total: subs.count }
-
}
-
end
-
-
# GET /api/v1/hr/employees/:id/vacation_balance
-
def vacation_balance
-
authorize @employee, :show_balance?
-
-
render json: {
-
data: {
-
employee_id: @employee.uuid,
-
accrued_days: @employee.accrued_vacation_days,
-
used_days: @employee.total_used_vacation_days,
-
scheduled_days: @employee.scheduled_vacation_days,
-
enjoyed_days: @employee.enjoyed_vacation_days,
-
pending_days: pending_vacation_days(@employee),
-
available_days: @employee.available_vacation_days
-
}
-
}
-
end
-
-
# POST /api/v1/hr/employees
-
def create
-
authorize ::Hr::Employee
-
-
@employee = ::Hr::Employee.new(employee_create_params)
-
@employee.organization = current_organization
-
-
if @employee.save
-
render json: {
-
data: employee_json(@employee, detailed: true),
-
message: "Empleado creado exitosamente"
-
}, status: :created
-
else
-
render json: {
-
error: @employee.errors.full_messages.join(", ")
-
}, status: :unprocessable_content
-
end
-
end
-
-
# POST /api/v1/hr/employees/:id/create_account
-
def create_account
-
authorize @employee, :create_account?
-
-
service = ::Hr::EmployeeAccountService.new(@employee)
-
-
if service.has_account?
-
return render json: {
-
error: "El empleado ya tiene una cuenta de usuario"
-
}, status: :unprocessable_content
-
end
-
-
user = service.create_account!
-
-
if user
-
render json: {
-
data: employee_json(@employee.reload, detailed: true),
-
user: {
-
id: user.uuid,
-
email: user.email,
-
must_change_password: user.must_change_password
-
},
-
message: "Cuenta de usuario creada exitosamente. El usuario debe cambiar su contraseña en el primer inicio de sesión."
-
}
-
else
-
render json: {
-
error: service.errors.join(", ")
-
}, status: :unprocessable_content
-
end
-
end
-
-
# GET /api/v1/hr/employees/org_chart
-
# Returns hierarchical organization chart data
-
def org_chart
-
authorize ::Hr::Employee, :index?
-
-
employees = ::Hr::Employee
-
.where(organization_id: current_organization.id)
-
.active
-
.order(last_name: :asc)
-
-
# Build tree structure
-
tree = build_org_tree(employees)
-
-
render json: {
-
data: tree,
-
meta: {
-
total_employees: employees.count,
-
top_level_count: tree.count
-
}
-
}
-
end
-
-
# POST /api/v1/hr/employees/:id/generate_document
-
def generate_document
-
authorize @employee, :update?
-
-
template = ::Templates::Template.find_by!(uuid: params[:template_id])
-
-
unless template.active?
-
return render json: { error: "El template no está activo" }, status: :unprocessable_content
-
end
-
-
context = {
-
employee: @employee,
-
organization: current_organization,
-
user: current_user
-
}
-
-
begin
-
# Use robust service for better variable replacement and formatting
-
service = ::Templates::RobustDocumentGeneratorService.new(template, context)
-
generated_doc = service.generate!
-
-
render json: {
-
data: {
-
id: generated_doc.uuid,
-
name: generated_doc.name,
-
status: generated_doc.status,
-
created_at: generated_doc.created_at.iso8601
-
},
-
message: "Documento generado exitosamente"
-
}, status: :created
-
rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
-
render json: {
-
error: "No se puede generar el documento. Faltan datos requeridos.",
-
missing_variables: e.message,
-
action_required: "complete_employee_data"
-
}, status: :unprocessable_entity
-
rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
rescue StandardError => e
-
Rails.logger.error "Error generating document: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
-
render json: { error: "Error al generar el documento: #{e.message}" }, status: :internal_server_error
-
end
-
end
-
-
private
-
-
def set_employee
-
@employee = ::Hr::Employee.find_by!(uuid: params[:id])
-
rescue Mongoid::Errors::DocumentNotFound
-
render json: { error: "Employee not found" }, status: :not_found
-
end
-
-
def employee_params
-
permitted = params.require(:employee).permit(
-
:first_name,
-
:last_name,
-
:employee_number,
-
:employment_status,
-
:employment_type,
-
:hire_date,
-
:termination_date,
-
:job_title,
-
:department,
-
:cost_center,
-
:date_of_birth,
-
:emergency_contact_name,
-
:emergency_contact_phone,
-
:supervisor_id,
-
# Contract fields
-
:contract_type,
-
:contract_template_id,
-
:contract_start_date,
-
:contract_end_date,
-
:contract_duration_value,
-
:contract_duration_unit,
-
:trial_period_days,
-
# Compensation fields
-
:salary,
-
:food_allowance,
-
:transport_allowance,
-
:payment_frequency,
-
:work_city,
-
# Personal identification
-
:identification_type,
-
:identification_number,
-
:place_of_birth,
-
:nationality,
-
:address,
-
:phone,
-
:personal_email
-
)
-
-
# Convert supervisor_id from UUID to internal ID
-
if permitted[:supervisor_id].present?
-
supervisor = ::Hr::Employee.find_by(uuid: permitted[:supervisor_id])
-
permitted[:supervisor_id] = supervisor&.id
-
end
-
-
permitted
-
end
-
-
# Params for creating a new employee (includes name fields)
-
def employee_create_params
-
permitted = params.require(:employee).permit(
-
:first_name,
-
:last_name,
-
:employee_number,
-
:employment_status,
-
:employment_type,
-
:hire_date,
-
:termination_date,
-
:job_title,
-
:department,
-
:cost_center,
-
:date_of_birth,
-
:emergency_contact_name,
-
:emergency_contact_phone,
-
:supervisor_id,
-
# Contract fields
-
:contract_type,
-
:contract_template_id,
-
:contract_start_date,
-
:contract_end_date,
-
:contract_duration_value,
-
:contract_duration_unit,
-
:trial_period_days,
-
# Compensation fields
-
:salary,
-
:food_allowance,
-
:transport_allowance,
-
:payment_frequency,
-
:work_city,
-
# Personal identification
-
:identification_type,
-
:identification_number,
-
:place_of_birth,
-
:nationality,
-
:address,
-
:phone,
-
:personal_email
-
)
-
-
# Convert supervisor_id from UUID to internal ID
-
if permitted[:supervisor_id].present?
-
supervisor = ::Hr::Employee.find_by(uuid: permitted[:supervisor_id])
-
permitted[:supervisor_id] = supervisor&.id
-
end
-
-
permitted
-
end
-
-
def apply_filters(scope)
-
scope = scope.where(department: params[:department]) if params[:department].present?
-
scope = scope.where(employment_status: params[:status]) if params[:status].present?
-
scope = filter_by_supervisor(scope)
-
filter_by_query(scope)
-
end
-
-
def filter_by_supervisor(scope)
-
return scope if params[:supervisor_id].blank?
-
-
supervisor = ::Hr::Employee.find_by(uuid: params[:supervisor_id])
-
supervisor ? scope.where(supervisor_id: supervisor.id) : scope
-
end
-
-
def filter_by_query(scope)
-
return scope if params[:q].blank?
-
-
query = /#{Regexp.escape(params[:q])}/i
-
scope.or({ first_name: query }, { last_name: query }, { employee_number: query })
-
end
-
-
def apply_sorting(scope)
-
sort_column = params[:sort_by].presence || "last_name"
-
sort_direction = params[:sort_direction]&.downcase == "desc" ? :desc : :asc
-
-
# Map frontend column names to database fields
-
column_map = {
-
"full_name" => :last_name,
-
"job_title" => :job_title,
-
"department" => :department,
-
"employment_status" => :employment_status,
-
"hire_date" => :hire_date,
-
"available_vacation_days" => :hire_date # Sort by hire_date as proxy for vacation days
-
}
-
-
db_column = column_map[sort_column] || :last_name
-
-
# For full_name, add secondary sort by first_name
-
if sort_column == "full_name"
-
scope.order(last_name: sort_direction, first_name: sort_direction)
-
else
-
scope.order(db_column => sort_direction, last_name: :asc)
-
end
-
end
-
-
def employee_json(employee, detailed: false) # rubocop:disable Metrics/MethodLength
-
json = {
-
id: employee.uuid,
-
employee_number: employee.employee_number,
-
first_name: employee.first_name,
-
last_name: employee.last_name,
-
full_name: employee.full_name,
-
email: employee.user&.email,
-
department: employee.department,
-
job_title: employee.job_title,
-
employment_status: employee.employment_status,
-
hire_date: employee.hire_date&.iso8601,
-
available_vacation_days: employee.available_vacation_days&.floor,
-
supervisor_id: employee.supervisor&.uuid
-
}
-
-
if detailed
-
json.merge!(
-
employment_type: employee.employment_type,
-
termination_date: employee.termination_date&.iso8601,
-
cost_center: employee.cost_center,
-
date_of_birth: employee.date_of_birth&.iso8601,
-
emergency_contact_name: employee.emergency_contact_name,
-
emergency_contact_phone: employee.emergency_contact_phone,
-
supervisor: employee.supervisor ? employee_summary(employee.supervisor) : nil,
-
supervisor_id: employee.supervisor&.uuid,
-
is_supervisor: employee.supervisor?,
-
is_hr_staff: employee.hr_staff?,
-
is_hr_manager: employee.hr_manager?,
-
vacation_balance_days: can_view_balance?(employee) ? employee.available_vacation_days : nil,
-
# Contract fields
-
contract_type: employee.contract_type,
-
contract_template_id: employee.contract_template_id,
-
contract_template_name: employee.contract_template&.name,
-
contract_start_date: employee.contract_start_date&.iso8601,
-
contract_end_date: employee.contract_end_date&.iso8601,
-
contract_duration_value: employee.contract_duration_value,
-
contract_duration_unit: employee.contract_duration_unit,
-
trial_period_days: employee.trial_period_days,
-
# Compensation fields
-
salary: employee.salary&.to_f,
-
food_allowance: employee.food_allowance&.to_f,
-
transport_allowance: employee.transport_allowance&.to_f,
-
payment_frequency: employee.payment_frequency,
-
work_city: employee.work_city,
-
# Personal identification
-
identification_type: employee.identification_type,
-
identification_number: employee.identification_number,
-
place_of_birth: employee.place_of_birth,
-
nationality: employee.nationality,
-
address: employee.address,
-
phone: employee.phone,
-
personal_email: employee.personal_email,
-
# Account status
-
has_account: employee.user_id.present?,
-
user_email: employee.user&.email
-
)
-
end
-
-
json
-
end
-
-
def employee_summary(employee)
-
{
-
id: employee.uuid,
-
name: employee.full_name,
-
job_title: employee.job_title
-
}
-
end
-
-
def pending_vacation_days(employee)
-
::Hr::VacationRequest
-
.where(employee_id: employee.id)
-
.where(:status.in => ["draft", "pending"])
-
.sum(:days_requested) || 0
-
end
-
-
def can_view_balance?(employee)
-
current_employee.id == employee.id ||
-
current_employee.hr_staff? ||
-
current_employee.supervises?(employee)
-
end
-
-
def build_org_tree(employees)
-
# Group by supervisor_id for efficient lookup
-
by_supervisor = employees.group_by(&:supervisor_id)
-
-
# Find top-level employees (no supervisor or supervisor outside org)
-
employee_ids = employees.pluck(:id).to_set
-
top_level = employees.select do |e|
-
e.supervisor_id.nil? || !employee_ids.include?(e.supervisor_id)
-
end
-
-
# Build tree recursively
-
top_level.map { |e| build_node(e, by_supervisor) }
-
end
-
-
def build_node(employee, by_supervisor)
-
children = by_supervisor[employee.id] || []
-
{
-
id: employee.uuid,
-
name: employee.full_name,
-
job_title: employee.job_title,
-
department: employee.department,
-
email: employee.user&.email,
-
photo_url: nil, # Future: add avatar support
-
subordinates_count: count_all_subordinates(employee, by_supervisor),
-
children: children.sort_by(&:last_name).map { |c| build_node(c, by_supervisor) }
-
}
-
end
-
-
def count_all_subordinates(employee, by_supervisor)
-
direct = by_supervisor[employee.id] || []
-
direct.count + direct.sum { |c| count_all_subordinates(c, by_supervisor) }
-
end
-
-
def current_employee
-
@current_employee ||= ::Hr::Employee.for_user(current_user) ||
-
::Hr::Employee.create!(
-
user_id: current_user.id,
-
organization_id: current_user.organization_id,
-
first_name: current_user.first_name,
-
last_name: current_user.last_name,
-
email: current_user.email,
-
employee_number: "EMP-#{current_user.id.to_s[-6..]}"
-
)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Hr
-
# Vacation requests management for employees
-
class VacationsController < BaseController
-
before_action :set_vacation, only: [:show, :update, :destroy, :submit, :cancel, :generate_document, :download_document, :sign_document]
-
-
# GET /api/v1/hr/vacations
-
def index
-
# Auto-marcar vacaciones pasadas como disfrutadas
-
current_employee.vacation_requests.approved.where(:end_date.lt => Date.current).each do |v|
-
v.mark_as_enjoyed!
-
rescue ::Hr::VacationRequest::InvalidStateError
-
next
-
end
-
-
@vacations = policy_scope(::Hr::VacationRequest)
-
.order(created_at: :desc)
-
-
@vacations = apply_filters(@vacations)
-
@vacations = paginate(@vacations)
-
-
render json: {
-
data: @vacations.map { |v| vacation_json(v) },
-
meta: pagination_meta(@vacations).merge(vacation_balance: vacation_balance_json)
-
}
-
end
-
-
# GET /api/v1/hr/vacations/:id
-
def show
-
authorize @vacation
-
-
render json: { data: vacation_json(@vacation, detailed: true), document: document_info }
-
end
-
-
# POST /api/v1/hr/vacations
-
def create
-
@vacation = ::Hr::VacationRequest.new(vacation_params)
-
@vacation.employee = current_employee
-
@vacation.organization = current_organization
-
-
authorize @vacation
-
-
if @vacation.save
-
# Auto-generate document immediately after creation
-
generate_vacation_document_if_available
-
-
render json: {
-
data: vacation_json(@vacation, detailed: true),
-
document: @vacation.document_uuid ? document_info : nil
-
}, status: :created
-
else
-
render json: { errors: @vacation.errors.full_messages }, status: :unprocessable_content
-
end
-
end
-
-
# PATCH /api/v1/hr/vacations/:id
-
def update
-
authorize @vacation
-
-
unless @vacation.draft?
-
return render json: { error: "Can only update draft requests" }, status: :unprocessable_content
-
end
-
-
if @vacation.update(vacation_params)
-
render json: { data: vacation_json(@vacation) }
-
else
-
render json: { errors: @vacation.errors.full_messages }, status: :unprocessable_content
-
end
-
end
-
-
# POST /api/v1/hr/vacations/:id/submit
-
def submit
-
authorize @vacation, :submit?
-
-
# Verify employee has signed the document
-
if @vacation.document_uuid
-
doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
-
if doc && !employee_has_signed?(doc)
-
return render json: {
-
error: "Debes firmar el documento antes de enviar la solicitud"
-
}, status: :unprocessable_content
-
end
-
end
-
-
@vacation.submit!(actor: current_employee)
-
-
render json: {
-
data: vacation_json(@vacation, detailed: true),
-
message: "Solicitud de vacaciones enviada para aprobación"
-
}
-
rescue ::Hr::VacationRequest::InvalidStateError,
-
::Hr::VacationRequest::ValidationError => e
-
render json: { error: e.message }, status: :unprocessable_content
-
end
-
-
# POST /api/v1/hr/vacations/:id/sign_document
-
def sign_document
-
authorize @vacation, :show?
-
-
unless @vacation.document_uuid
-
return render json: { error: "No hay documento para firmar" }, status: :not_found
-
end
-
-
generated_doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
-
unless generated_doc
-
return render json: { error: "Documento no encontrado" }, status: :not_found
-
end
-
-
# Get user's signature
-
signature = current_user.signatures.find_by(is_default: true) || current_user.signatures.first
-
unless signature
-
return render json: { error: "No tienes una firma configurada. Ve a tu perfil para crear una." }, status: :unprocessable_content
-
end
-
-
# Find the employee signatory slot
-
employee_sig = generated_doc.signatures.find { |s| s["signatory_type_code"] == "employee" }
-
unless employee_sig
-
return render json: { error: "No hay espacio de firma para empleado en este documento" }, status: :unprocessable_content
-
end
-
-
if employee_sig["signed_at"].present?
-
return render json: { error: "Ya has firmado este documento" }, status: :unprocessable_content
-
end
-
-
# Sign the document
-
generated_doc.sign!(user: current_user, signature: signature)
-
-
render json: {
-
data: vacation_json(@vacation, detailed: true),
-
document: document_info,
-
message: "Documento firmado exitosamente"
-
}
-
rescue StandardError => e
-
Rails.logger.error("Error signing vacation document: #{e.message}")
-
render json: { error: "Error al firmar: #{e.message}" }, status: :unprocessable_content
-
end
-
-
# POST /api/v1/hr/vacations/:id/cancel
-
def cancel
-
authorize @vacation, :cancel?
-
-
@vacation.cancel!(actor: current_employee, reason: params[:reason])
-
-
render json: {
-
data: vacation_json(@vacation),
-
message: "Vacation request cancelled"
-
}
-
rescue ::Hr::VacationRequest::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_content
-
rescue ::Hr::VacationRequest::AuthorizationError => e
-
render json: { error: e.message }, status: :forbidden
-
end
-
-
# DELETE /api/v1/hr/vacations/:id
-
def destroy
-
authorize @vacation, :destroy?
-
-
# Check if vacation can be deleted
-
unless can_delete_vacation?
-
return render json: {
-
error: "No puedes eliminar esta solicitud. Solo se pueden eliminar solicitudes que no hayan sido firmadas o autorizadas por otros."
-
}, status: :forbidden
-
end
-
-
# Delete associated document if exists
-
if @vacation.document_uuid
-
doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
-
doc&.destroy
-
end
-
-
@vacation.destroy
-
-
render json: { message: "Solicitud de vacaciones eliminada exitosamente" }
-
end
-
-
# POST /api/v1/hr/vacations/:id/generate_document
-
def generate_document
-
authorize @vacation, :show?
-
-
# Find vacation template
-
template = find_vacation_template
-
unless template
-
return render json: {
-
error: "No hay template activo para solicitud de vacaciones"
-
}, status: :not_found
-
end
-
-
# Build context for variable resolution
-
context = {
-
employee: @vacation.employee,
-
organization: current_organization,
-
request: @vacation
-
}
-
-
# Generate document
-
generator = ::Templates::RobustDocumentGeneratorService.new(template, context)
-
generated_doc = generator.generate!
-
-
# Link document to vacation request
-
@vacation.update!(document_uuid: generated_doc.uuid)
-
-
render json: {
-
data: vacation_json(@vacation, detailed: true),
-
document: generated_document_json(generated_doc),
-
message: "Documento generado exitosamente"
-
}
-
rescue StandardError => e
-
Rails.logger.error("Error generating vacation document: #{e.message}")
-
render json: { error: "Error al generar documento: #{e.message}" }, status: :unprocessable_content
-
end
-
-
# GET /api/v1/hr/vacations/:id/download_document
-
def download_document
-
authorize @vacation, :show?
-
-
unless @vacation.document_uuid
-
return render json: { error: "No hay documento generado" }, status: :not_found
-
end
-
-
generated_doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
-
unless generated_doc
-
return render json: { error: "Documento no encontrado" }, status: :not_found
-
end
-
-
# Get PDF content from GridFS
-
pdf_content = generated_doc.file_content
-
unless pdf_content
-
return render json: { error: "Archivo PDF no encontrado" }, status: :not_found
-
end
-
-
send_data pdf_content,
-
type: "application/pdf",
-
filename: "solicitud_vacaciones_#{@vacation.request_number}.pdf",
-
disposition: "inline"
-
end
-
-
private
-
-
def set_vacation
-
@vacation = ::Hr::VacationRequest.find_by!(uuid: params[:id])
-
rescue Mongoid::Errors::DocumentNotFound
-
render json: { error: "Vacation request not found" }, status: :not_found
-
end
-
-
def vacation_params
-
params.require(:vacation).permit(
-
:vacation_type,
-
:start_date,
-
:end_date,
-
:days_requested,
-
:reason,
-
:notes
-
)
-
end
-
-
def apply_filters(scope) # rubocop:disable Metrics/AbcSize
-
scope = scope.where(status: params[:status]) if params[:status].present?
-
scope = scope.where(vacation_type: params[:type]) if params[:type].present?
-
scope = scope.where(:start_date.gte => params[:from]) if params[:from].present?
-
scope = scope.where(:end_date.lte => params[:to]) if params[:to].present?
-
scope
-
end
-
-
def vacation_json(vacation, detailed: false) # rubocop:disable Metrics/MethodLength
-
# Check if document exists and needs employee signature
-
needs_signature = false
-
if vacation.document_uuid.present?
-
doc = ::Templates::GeneratedDocument.find_by(uuid: vacation.document_uuid)
-
needs_signature = doc && !employee_has_signed?(doc)
-
end
-
-
json = {
-
id: vacation.uuid,
-
request_number: vacation.request_number,
-
vacation_type: vacation.vacation_type,
-
start_date: vacation.start_date&.iso8601,
-
end_date: vacation.end_date&.iso8601,
-
days_requested: vacation.days_requested,
-
status: vacation.status,
-
status_label: vacation.status_label,
-
submitted_at: vacation.submitted_at&.iso8601,
-
created_at: vacation.created_at.iso8601,
-
has_document: vacation.document_uuid.present?,
-
needs_employee_signature: needs_signature,
-
can_delete: can_delete_for_user?(vacation)
-
}
-
-
if detailed
-
json.merge!(
-
reason: vacation.reason,
-
notes: vacation.notes,
-
decided_at: vacation.decided_at&.iso8601,
-
decision_reason: vacation.decision_reason,
-
approved_by_name: vacation.approved_by_name,
-
approver: vacation.approver ? employee_summary(vacation.approver) : nil,
-
document_uuid: vacation.document_uuid,
-
history: vacation.history
-
)
-
end
-
-
json
-
end
-
-
def employee_summary(employee)
-
{
-
id: employee.uuid,
-
name: employee.full_name,
-
email: employee.user&.email
-
}
-
end
-
-
def vacation_balance_json
-
emp = current_employee
-
{
-
accrued: emp.accrued_vacation_days,
-
scheduled: emp.scheduled_vacation_days,
-
enjoyed: emp.enjoyed_vacation_days,
-
total_used: emp.total_used_vacation_days,
-
available: emp.available_vacation_days
-
}
-
end
-
-
def current_employee
-
@current_employee ||= ::Hr::Employee.for_user(current_user) ||
-
::Hr::Employee.create!(
-
user: current_user,
-
organization: current_organization,
-
job_title: current_user.title,
-
department: current_user.department,
-
hire_date: Date.current,
-
vacation_balance_days: 15.0
-
)
-
end
-
-
def find_vacation_template
-
::Templates::Template.where(
-
organization_id: current_organization.id,
-
category: "vacation",
-
status: "active"
-
).first
-
end
-
-
def generate_vacation_document_if_available
-
template = find_vacation_template
-
return unless template
-
-
context = {
-
employee: @vacation.employee,
-
organization: current_organization,
-
request: @vacation,
-
user: current_user
-
}
-
-
generator = ::Templates::RobustDocumentGeneratorService.new(template, context)
-
generated_doc = generator.generate!
-
-
@vacation.update!(document_uuid: generated_doc.uuid)
-
rescue StandardError => e
-
# Log but don't fail the submit if document generation fails
-
Rails.logger.error("Error auto-generating vacation document: #{e.message}")
-
Rails.logger.error(e.backtrace.first(5).join("\n"))
-
end
-
-
def generated_document_json(doc)
-
{
-
uuid: doc.uuid,
-
name: doc.name,
-
status: doc.status,
-
has_pdf: doc.draft_file_id.present? || doc.final_file_id.present?,
-
created_at: doc.created_at.iso8601
-
}
-
end
-
-
def document_info
-
return nil unless @vacation.document_uuid
-
-
doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
-
return nil unless doc
-
-
{
-
uuid: doc.uuid,
-
name: doc.name,
-
status: doc.status,
-
has_pdf: doc.draft_file_id.present? || doc.final_file_id.present?,
-
employee_signed: employee_has_signed?(doc),
-
signatures: doc.signatures.map do |sig|
-
{
-
signatory_type_code: sig["signatory_type_code"],
-
label: sig["label"],
-
signed: sig["signed_at"].present?,
-
signed_at: sig["signed_at"],
-
signed_by: sig["signed_by_name"]
-
}
-
end
-
}
-
end
-
-
def employee_has_signed?(doc)
-
employee_sig = doc.signatures.find { |s| s["signatory_type_code"] == "employee" }
-
employee_sig && employee_sig["signed_at"].present?
-
end
-
-
# Check if vacation can be deleted (for action)
-
# Admin/HR can delete any request, owner has restrictions
-
def can_delete_vacation?
-
can_delete_for_user?(@vacation)
-
end
-
-
# Check if current user can delete this vacation (for JSON response)
-
def can_delete_for_user?(vacation)
-
# Admin and HR can delete any vacation
-
return true if current_user.admin? || current_employee&.hr_manager?
-
-
can_delete_vacation_for?(vacation)
-
end
-
-
def can_delete_vacation_for?(vacation)
-
# For owner: check restrictions
-
return false unless vacation.employee_id == current_employee&.id
-
-
# Cannot delete if already approved, enjoyed, or in certain final states
-
return false if vacation.approved? || vacation.enjoyed?
-
-
# If there's a document, check that no one else has signed
-
if vacation.document_uuid
-
doc = ::Templates::GeneratedDocument.find_by(uuid: vacation.document_uuid)
-
if doc
-
# Check for any signatures from non-employee signatories
-
other_signatures = doc.signatures.select do |sig|
-
sig["signatory_type_code"] != "employee" && sig["signed_at"].present?
-
end
-
return false if other_signatures.any?
-
end
-
end
-
-
true
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Legal
-
class ContractApprovalsController < BaseController
-
before_action :set_contract, only: %i[show approve reject sign]
-
-
# GET /api/v1/legal/contract_approvals
-
def index
-
status_filter = params[:status] || "pending"
-
-
contracts = ::Legal::Contract
-
.where(organization_id: current_organization.id)
-
.includes(:third_party, :requested_by)
-
-
if status_filter == "pending"
-
# Get contracts pending approval that user can approve
-
pending_approvals = contracts.pending_approval.select { |c| c.can_approve?(current_user) }
-
-
# Also get contracts pending signatures that user can sign
-
pending_signatures = contracts.pending_signatures.select { |c| can_sign_contract?(c) }
-
-
contracts = pending_approvals + pending_signatures
-
elsif status_filter == "signatures"
-
# Only pending signatures
-
contracts = contracts.pending_signatures.select { |c| can_sign_contract?(c) }
-
else
-
# History - approved/rejected by this user or all for admins
-
if current_user.admin? || current_user.has_role?("legal")
-
contracts = contracts.any_of(
-
{ status: "approved" },
-
{ status: "rejected" },
-
{ status: "active" },
-
{ status: "terminated" },
-
{ status: "cancelled" }
-
).order(updated_at: :desc).limit(50).to_a
-
else
-
# Filter to contracts where user approved or signed
-
contracts = contracts.any_of(
-
{ status: "approved" },
-
{ status: "rejected" },
-
{ status: "active" }
-
).order(updated_at: :desc).limit(50).to_a.select do |c|
-
c.approvals.any? { |a| a.approver_id == current_user.id.to_s } ||
-
(c.generated_document&.signatures&.any? { |s| s["user_id"] == current_user.id.to_s && s["status"] == "signed" })
-
end
-
end
-
end
-
-
# Calculate stats
-
pending_approval_count = ::Legal::Contract
-
.where(organization_id: current_organization.id)
-
.pending_approval
-
.count { |c| c.can_approve?(current_user) }
-
-
pending_signatures_count = ::Legal::Contract
-
.where(organization_id: current_organization.id)
-
.pending_signatures
-
.count { |c| can_sign_contract?(c) }
-
-
render json: {
-
data: contracts.map { |c| approval_json(c) },
-
meta: {
-
status: status_filter,
-
total_pending: pending_approval_count + pending_signatures_count,
-
total_pending_approvals: pending_approval_count,
-
total_pending_signatures: pending_signatures_count
-
}
-
}
-
end
-
-
# GET /api/v1/legal/contract_approvals/:id
-
def show
-
render json: { data: approval_json(@contract, detailed: true) }
-
end
-
-
# POST /api/v1/legal/contract_approvals/:id/approve
-
def approve
-
role = determine_user_role
-
-
unless role
-
return render json: { error: "No tienes un rol de aprobación válido" }, status: :forbidden
-
end
-
-
@contract.approve!(actor: current_user, role: role, notes: params[:notes])
-
-
render json: {
-
data: approval_json(@contract),
-
message: all_approved? ? "Contrato aprobado completamente" : "Aprobación registrada, pendiente siguiente nivel"
-
}
-
rescue ::Legal::Contract::InvalidStateError, ::Legal::Contract::AuthorizationError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contract_approvals/:id/reject
-
def reject
-
role = determine_user_role
-
-
unless role
-
return render json: { error: "No tienes un rol de aprobación válido" }, status: :forbidden
-
end
-
-
unless params[:reason].present?
-
return render json: { error: "Se requiere un motivo de rechazo" }, status: :unprocessable_entity
-
end
-
-
@contract.reject!(actor: current_user, role: role, reason: params[:reason])
-
-
render json: {
-
data: approval_json(@contract),
-
message: "Contrato rechazado"
-
}
-
rescue ::Legal::Contract::InvalidStateError, ::Legal::Contract::AuthorizationError, ArgumentError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contract_approvals/:id/sign
-
# Sign the contract document
-
def sign
-
unless @contract.pending_signatures?
-
return render json: { error: "Este contrato no está pendiente de firmas" }, status: :unprocessable_entity
-
end
-
-
doc = @contract.generated_document
-
unless doc
-
return render json: { error: "Este contrato no tiene documento generado" }, status: :not_found
-
end
-
-
unless doc.can_be_signed_by?(current_user)
-
return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
-
end
-
-
# Get user's default signature
-
signature = current_user.signatures.active.default_signature.first || current_user.signatures.active.first
-
unless signature
-
return render json: { error: "No tienes una firma digital configurada. Configura tu firma en tu perfil." }, status: :unprocessable_entity
-
end
-
-
# Get custom position from params if provided
-
custom_position = nil
-
if params[:signature_position].present?
-
custom_position = {
-
x: params[:signature_position][:x_position]&.to_i,
-
y: params[:signature_position][:y_position]&.to_i,
-
width: params[:signature_position][:width]&.to_i,
-
height: params[:signature_position][:height]&.to_i
-
}.compact
-
custom_position = nil if custom_position.empty?
-
end
-
-
doc.sign!(user: current_user, signature: signature, custom_position: custom_position)
-
-
# Refresh document to get updated signature status
-
doc.reload
-
-
# If all signatures complete, mark contract as approved
-
if doc.all_required_signed? && @contract.pending_signatures?
-
@contract.complete_signatures!(actor: current_user)
-
end
-
-
@contract.reload
-
-
render json: {
-
data: approval_json(@contract),
-
message: "Documento firmado exitosamente",
-
all_signed: doc.all_required_signed?
-
}
-
rescue ::Templates::GeneratedDocument::SignatureError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
private
-
-
def set_contract
-
@contract = ::Legal::Contract.find_by(uuid: params[:id])
-
render json: { error: "Contrato no encontrado" }, status: :not_found unless @contract
-
end
-
-
def determine_user_role
-
# Determine which approval role the user can act as
-
return @contract.current_approver_role if @contract.can_approve?(current_user)
-
-
# Check if user has any approval role
-
roles_map = {
-
"admin" => %w[area_manager legal general_manager ceo],
-
"ceo" => %w[ceo],
-
"general_manager" => %w[general_manager],
-
"legal" => %w[legal],
-
"manager" => %w[area_manager]
-
}
-
-
user_roles = current_user.roles || []
-
user_roles.each do |user_role|
-
approval_roles = roles_map[user_role] || []
-
return @contract.current_approver_role if approval_roles.include?(@contract.current_approver_role)
-
end
-
-
nil
-
end
-
-
def all_approved?
-
@contract.approved?
-
end
-
-
def can_sign_contract?(contract)
-
return false unless contract.pending_signatures?
-
doc = contract.generated_document
-
return false unless doc
-
doc.can_be_signed_by?(current_user)
-
end
-
-
def approval_json(contract, detailed: false)
-
doc = contract.generated_document
-
-
# Build signatures info
-
signatures_info = []
-
can_sign = false
-
if doc
-
signatures_info = doc.signatures.map do |sig|
-
# Get signatory from template to get position info
-
signatory_uuid = sig["signatory_id"]
-
signatory = signatory_uuid.present? ? doc.template&.signatories&.where(uuid: signatory_uuid)&.first : nil
-
sig_box = signatory&.signature_box || {}
-
-
{
-
signatory_label: sig["signatory_label"],
-
signatory_type_code: sig["signatory_type_code"],
-
user_name: sig["user_name"],
-
user_id: sig["user_id"],
-
status: sig["status"],
-
required: sig["required"],
-
signed_at: sig["signed_at"],
-
signed_by_name: sig["signed_by_name"],
-
is_mine: sig["user_id"] == current_user.id.to_s,
-
# Position info for visual editor
-
x_position: sig_box[:x] || 350,
-
y_position: sig_box[:y] || 700,
-
width: sig_box[:width] || 200,
-
height: sig_box[:height] || 80,
-
page_number: sig_box[:page] || 1
-
}
-
end
-
# Only allow signing if contract is in pending_signatures status
-
can_sign = contract.pending_signatures? && doc.can_be_signed_by?(current_user)
-
end
-
-
data = {
-
id: contract.uuid,
-
contract_number: contract.contract_number,
-
title: contract.title,
-
contract_type: contract.contract_type,
-
type_label: contract.type_label,
-
status: contract.status,
-
status_label: contract.status_label,
-
amount: contract.amount.to_f,
-
currency: contract.currency,
-
start_date: contract.start_date,
-
end_date: contract.end_date,
-
approval_level: contract.approval_level,
-
approval_level_label: contract.approval_level_label,
-
current_approver_role: contract.current_approver_role,
-
current_approver_label: contract.current_approver_label,
-
approval_progress: contract.approval_progress,
-
can_approve: contract.can_approve?(current_user),
-
can_sign: can_sign,
-
submitted_at: contract.submitted_at,
-
third_party: contract.third_party ? {
-
id: contract.third_party.uuid,
-
display_name: contract.third_party.display_name,
-
type: contract.third_party.third_party_type
-
} : nil,
-
requested_by: contract.requested_by ? {
-
id: contract.requested_by.uuid,
-
name: contract.requested_by.full_name
-
} : nil,
-
has_document: contract.document_uuid.present?,
-
document_page_count: doc&.template&.pdf_page_count || 1,
-
pdf_width: doc&.template&.pdf_width || 612,
-
pdf_height: doc&.template&.pdf_height || 792,
-
approvals: contract.approvals.order(order: :asc).map { |a|
-
{
-
role: a.role,
-
role_label: a.role_label,
-
status: a.status,
-
order: a.order,
-
decided_at: a.decided_at,
-
approver_name: a.approver_name,
-
notes: a.notes,
-
reason: a.reason
-
}
-
},
-
document_signatures: signatures_info,
-
document_signatures_status: contract.document_signatures_status
-
}
-
-
if detailed
-
data.merge!(
-
description: contract.description,
-
payment_terms: contract.payment_terms,
-
approved_at: contract.approved_at,
-
rejected_at: contract.rejected_at,
-
rejection_reason: contract.rejection_reason,
-
history: contract.history.last(20)
-
)
-
end
-
-
data
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Legal
-
class ContractsController < BaseController
-
before_action :set_contract, only: %i[show update destroy submit activate terminate cancel archive unarchive generate_document download_document sign_document]
-
-
# GET /api/v1/legal/contracts
-
def index
-
authorize ::Legal::Contract
-
-
contracts = policy_scope(::Legal::Contract)
-
.includes(:third_party, :requested_by)
-
.order(created_at: :desc)
-
-
# Archived filter - by default hide archived, show only archived when requested
-
if params[:archived] == "true"
-
contracts = contracts.archived
-
elsif params[:include_archived] != "true"
-
contracts = contracts.not_archived
-
end
-
-
# Filters
-
contracts = contracts.by_type(params[:type]) if params[:type].present?
-
contracts = contracts.where(status: params[:status]) if params[:status].present? && params[:status] != "archived"
-
contracts = contracts.by_third_party(params[:third_party_id]) if params[:third_party_id].present?
-
contracts = contracts.search(params[:search]) if params[:search].present?
-
-
# Pagination
-
page = (params[:page] || 1).to_i
-
per_page = (params[:per_page] || 20).to_i
-
total = contracts.count
-
contracts = contracts.skip((page - 1) * per_page).limit(per_page)
-
-
render json: {
-
data: contracts.map { |c| contract_json(c) },
-
meta: {
-
current_page: page,
-
per_page: per_page,
-
total_count: total,
-
total_pages: (total.to_f / per_page).ceil
-
}
-
}
-
end
-
-
# GET /api/v1/legal/contracts/:id
-
def show
-
authorize @contract
-
render json: { data: contract_json(@contract, detailed: true) }
-
end
-
-
# POST /api/v1/legal/contracts
-
def create
-
authorize ::Legal::Contract
-
-
@contract = ::Legal::Contract.new(contract_params)
-
@contract.organization = current_organization
-
@contract.requested_by = current_user
-
-
if @contract.save
-
render json: { data: contract_json(@contract) }, status: :created
-
else
-
render json: { errors: @contract.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /api/v1/legal/contracts/:id
-
def update
-
authorize @contract
-
-
if @contract.update(contract_params)
-
render json: { data: contract_json(@contract) }
-
else
-
render json: { errors: @contract.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/legal/contracts/:id
-
def destroy
-
authorize @contract
-
-
# Also delete associated generated document if exists
-
if @contract.document_uuid
-
doc = ::Templates::GeneratedDocument.find_by(uuid: @contract.document_uuid)
-
doc&.destroy
-
end
-
-
if @contract.destroy
-
render json: { message: "Contrato eliminado correctamente" }
-
else
-
render json: { errors: @contract.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# POST /api/v1/legal/contracts/:id/submit
-
def submit
-
authorize @contract
-
-
# Validate contract has required data
-
unless @contract.third_party
-
return render json: { error: "El contrato debe tener un tercero asignado" }, status: :unprocessable_entity
-
end
-
-
unless @contract.amount && @contract.amount > 0
-
return render json: { error: "El contrato debe tener un monto válido" }, status: :unprocessable_entity
-
end
-
-
unless @contract.start_date && @contract.end_date
-
return render json: { error: "El contrato debe tener fechas de inicio y fin" }, status: :unprocessable_entity
-
end
-
-
# If contract has a template, generate document first to validate variables
-
if @contract.template_id && !@contract.document_uuid
-
template = ::Templates::Template.find_by(uuid: @contract.template_id)
-
if template
-
context = {
-
third_party: @contract.third_party,
-
contract: @contract,
-
organization: current_organization,
-
user: current_user
-
}
-
-
begin
-
service = ::Templates::RobustDocumentGeneratorService.new(template, context)
-
doc = service.generate!
-
@contract.update!(document_uuid: doc.uuid)
-
rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
-
return render json: {
-
error: "No se puede enviar a aprobación. Faltan datos para generar el documento.",
-
missing_variables: e.message,
-
action_required: "complete_data"
-
}, status: :unprocessable_entity
-
rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
-
return render json: {
-
error: "Error al generar el documento: #{e.message}",
-
action_required: "fix_template"
-
}, status: :unprocessable_entity
-
end
-
end
-
end
-
-
@contract.submit!(actor: current_user)
-
render json: {
-
data: contract_json(@contract),
-
message: "Contrato enviado a aprobación",
-
document_generated: @contract.document_uuid.present?
-
}
-
rescue ::Legal::Contract::InvalidStateError, ::Legal::Contract::ValidationError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contracts/:id/activate
-
def activate
-
authorize @contract
-
-
@contract.activate!(actor: current_user)
-
render json: {
-
data: contract_json(@contract),
-
message: "Contrato activado"
-
}
-
rescue ::Legal::Contract::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contracts/:id/terminate
-
def terminate
-
authorize @contract
-
-
reason = params[:reason]
-
@contract.terminate!(actor: current_user, reason: reason)
-
render json: {
-
data: contract_json(@contract),
-
message: "Contrato terminado"
-
}
-
rescue ::Legal::Contract::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contracts/:id/cancel
-
def cancel
-
authorize @contract
-
-
reason = params[:reason]
-
@contract.cancel!(actor: current_user, reason: reason)
-
render json: {
-
data: contract_json(@contract),
-
message: "Contrato cancelado"
-
}
-
rescue ::Legal::Contract::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contracts/:id/archive
-
def archive
-
authorize @contract
-
-
@contract.archive!(actor: current_user)
-
render json: {
-
data: contract_json(@contract),
-
message: "Contrato archivado"
-
}
-
rescue ::Legal::Contract::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contracts/:id/unarchive
-
def unarchive
-
authorize @contract
-
-
@contract.unarchive!(actor: current_user)
-
render json: {
-
data: contract_json(@contract),
-
message: "Contrato restaurado del archivo"
-
}
-
rescue ::Legal::Contract::InvalidStateError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contracts/:id/generate_document
-
def generate_document
-
authorize @contract
-
-
# Use provided template_id or fall back to contract's stored template
-
template_id = params[:template_id].presence || @contract.template_id
-
unless template_id
-
return render json: { error: "No se especificó un template y el contrato no tiene uno asociado" }, status: :unprocessable_entity
-
end
-
-
template = ::Templates::Template.find_by(uuid: template_id)
-
unless template
-
return render json: { error: "Template no encontrado" }, status: :not_found
-
end
-
-
context = {
-
third_party: @contract.third_party,
-
contract: @contract,
-
organization: current_organization,
-
user: current_user
-
}
-
-
service = ::Templates::RobustDocumentGeneratorService.new(template, context)
-
doc = service.generate!
-
-
@contract.update!(document_uuid: doc.uuid)
-
-
# Initialize signature workflow if template has signatories
-
if template.signatories.any?
-
doc.initialize_signatures!
-
end
-
-
render json: {
-
data: contract_json(@contract),
-
document: {
-
uuid: doc.uuid,
-
status: doc.status,
-
pending_signatures: doc.pending_signatures_count,
-
signatures: doc.signatures
-
},
-
message: "Documento generado correctamente"
-
}
-
rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
-
render json: {
-
error: "Variables faltantes para generar el documento",
-
missing_variables: e.message
-
}, status: :unprocessable_entity
-
rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /api/v1/legal/contracts/validate_template
-
# Validates that all template variables can be resolved with the given data
-
def validate_template
-
authorize ::Legal::Contract
-
-
template_id = params[:template_id]
-
third_party_id = params[:third_party_id]
-
-
unless template_id.present?
-
return render json: { error: "Se requiere template_id" }, status: :unprocessable_entity
-
end
-
-
template = ::Templates::Template.find_by(uuid: template_id)
-
unless template
-
return render json: { error: "Template no encontrado" }, status: :not_found
-
end
-
-
third_party = nil
-
if third_party_id.present?
-
third_party = ::Legal::ThirdParty.find_by(uuid: third_party_id)
-
unless third_party
-
return render json: { error: "Tercero no encontrado" }, status: :not_found
-
end
-
end
-
-
# Build a temporary contract object with the provided data
-
temp_contract = ::Legal::Contract.new(
-
title: params[:title] || "Validación",
-
contract_type: params[:contract_type] || "services",
-
amount: params[:amount]&.to_f,
-
currency: params[:currency] || "COP",
-
start_date: params[:start_date],
-
end_date: params[:end_date],
-
description: params[:description],
-
payment_terms: params[:payment_terms],
-
payment_frequency: params[:payment_frequency],
-
organization: current_organization,
-
third_party: third_party
-
)
-
-
# Create context for variable resolution
-
context = {
-
third_party: third_party,
-
contract: temp_contract,
-
organization: current_organization
-
}
-
-
# Validate variables
-
resolver = ::Templates::VariableResolverService.new(context)
-
validation = resolver.validate_for_template(template)
-
-
# Group missing variables by source for better UX
-
missing_by_source = validation[:missing].group_by { |m| m[:source] }
-
-
render json: {
-
valid: validation[:valid],
-
template: {
-
id: template.uuid,
-
name: template.name,
-
total_variables: validation[:total_variables]
-
},
-
validation: {
-
resolved_count: validation[:resolved_count],
-
missing_count: validation[:missing_count],
-
missing: validation[:missing],
-
missing_by_source: missing_by_source
-
},
-
message: validation[:valid] ? "Todos los datos están completos" : "Faltan datos requeridos para generar el documento"
-
}
-
end
-
-
# GET /api/v1/legal/contracts/:id/download_document
-
def download_document
-
authorize @contract
-
-
unless @contract.document_uuid
-
return render json: { error: "Este contrato no tiene documento generado. Por favor genere el documento primero." }, status: :not_found
-
end
-
-
generated_doc = ::Templates::GeneratedDocument.find_by(uuid: @contract.document_uuid)
-
unless generated_doc
-
return render json: { error: "El documento asociado no fue encontrado. Por favor regenere el documento." }, status: :not_found
-
end
-
-
file_id = generated_doc.final_file_id || generated_doc.draft_file_id || generated_doc.original_draft_file_id
-
unless file_id
-
return render json: { error: "El archivo PDF no está disponible. Esto puede ocurrir si hubo un error durante la generación. Por favor regenere el documento." }, status: :unprocessable_entity
-
end
-
-
begin
-
grid_file = Mongoid::GridFs.get(file_id)
-
send_data grid_file.data,
-
type: "application/pdf",
-
disposition: "attachment",
-
filename: generated_doc.file_name
-
rescue Mongoid::Errors::DocumentNotFound
-
render json: { error: "El archivo PDF no fue encontrado en el almacenamiento. Por favor regenere el documento." }, status: :not_found
-
end
-
end
-
-
# POST /api/v1/legal/contracts/:id/sign_document
-
# Signs the contract document with the current user's digital signature
-
def sign_document
-
authorize @contract
-
-
unless @contract.pending_signatures?
-
return render json: { error: "Este contrato no está pendiente de firmas" }, status: :unprocessable_entity
-
end
-
-
doc = @contract.generated_document
-
unless doc
-
return render json: { error: "Este contrato no tiene documento generado" }, status: :not_found
-
end
-
-
unless doc.can_be_signed_by?(current_user)
-
return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
-
end
-
-
# Get user's default signature
-
signature = current_user.signatures.active.default_signature.first || current_user.signatures.active.first
-
unless signature
-
return render json: { error: "No tienes una firma digital configurada. Configura tu firma en tu perfil." }, status: :unprocessable_entity
-
end
-
-
doc.sign!(user: current_user, signature: signature)
-
-
# Refresh document to get updated signature status
-
doc.reload
-
-
# If all signatures complete and contract is still pending_signatures, mark complete
-
if doc.all_required_signed? && @contract.pending_signatures?
-
@contract.complete_signatures!(actor: current_user)
-
end
-
-
@contract.reload
-
-
render json: {
-
data: contract_json(@contract, detailed: true),
-
message: "Documento firmado exitosamente",
-
all_signed: doc.all_required_signed?
-
}
-
rescue ::Templates::GeneratedDocument::SignatureError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
private
-
-
def set_contract
-
@contract = ::Legal::Contract.find_by(uuid: params[:id])
-
render json: { error: "Contrato no encontrado" }, status: :not_found unless @contract
-
end
-
-
def contract_params
-
params.require(:contract).permit(
-
:title, :description, :contract_type,
-
:start_date, :end_date, :signature_date,
-
:amount, :currency, :payment_terms, :payment_frequency,
-
:third_party_id, :template_id,
-
:auto_renewal, :renewal_notice_days, :renewal_terms
-
).tap do |p|
-
# Convert third_party_id from UUID to ObjectId
-
if p[:third_party_id].present?
-
tp = ::Legal::ThirdParty.find_by(uuid: p[:third_party_id])
-
p[:third_party_id] = tp&.id
-
end
-
end
-
end
-
-
def contract_json(contract, detailed: false)
-
data = {
-
id: contract.uuid,
-
contract_number: contract.contract_number,
-
title: contract.title,
-
contract_type: contract.contract_type,
-
type_label: contract.type_label,
-
status: contract.status,
-
status_label: contract.status_label,
-
amount: contract.amount.to_f,
-
currency: contract.currency,
-
start_date: contract.start_date,
-
end_date: contract.end_date,
-
duration_days: contract.duration_days,
-
days_until_expiry: contract.days_until_expiry,
-
expiring_soon: contract.expiring_soon?,
-
approval_level: contract.approval_level,
-
approval_level_label: contract.approval_level_label,
-
current_approver_role: contract.current_approver_role,
-
current_approver_label: contract.current_approver_label,
-
approval_progress: contract.approval_progress,
-
can_approve: contract.can_approve?(current_user),
-
has_document: contract.document_uuid.present?,
-
third_party: contract.third_party ? {
-
id: contract.third_party.uuid,
-
code: contract.third_party.code,
-
display_name: contract.third_party.display_name,
-
type: contract.third_party.third_party_type
-
} : nil,
-
requested_by: contract.requested_by ? {
-
id: contract.requested_by.uuid,
-
name: contract.requested_by.full_name
-
} : nil,
-
created_at: contract.created_at,
-
updated_at: contract.updated_at,
-
can_delete: ::Legal::ContractPolicy.new(current_user, contract).destroy?
-
}
-
-
if detailed
-
doc = contract.generated_document
-
doc_signatures = doc ? doc.signatures.map do |sig|
-
{
-
signatory_label: sig["signatory_label"],
-
signatory_type_code: sig["signatory_type_code"],
-
user_name: sig["user_name"],
-
user_id: sig["user_id"],
-
status: sig["status"],
-
required: sig["required"],
-
signed_at: sig["signed_at"],
-
signed_by_name: sig["signed_by_name"]
-
}
-
end : []
-
-
data.merge!(
-
description: contract.description,
-
signature_date: contract.signature_date,
-
payment_terms: contract.payment_terms,
-
payment_frequency: contract.payment_frequency,
-
auto_renewal: contract.auto_renewal,
-
renewal_notice_days: contract.renewal_notice_days,
-
renewal_terms: contract.renewal_terms,
-
submitted_at: contract.submitted_at,
-
approved_at: contract.approved_at,
-
rejected_at: contract.rejected_at,
-
rejection_reason: contract.rejection_reason,
-
document_uuid: contract.document_uuid,
-
template_id: contract.template_id,
-
approvals: contract.approvals.order(order: :asc).map { |a|
-
{
-
role: a.role,
-
role_label: a.role_label,
-
status: a.status,
-
order: a.order,
-
decided_at: a.decided_at,
-
approver_name: a.approver_name,
-
notes: a.notes,
-
reason: a.reason
-
}
-
},
-
document_signatures: doc_signatures,
-
document_signatures_status: contract.document_signatures_status,
-
can_sign_document: doc&.can_be_signed_by?(current_user) || false,
-
history: contract.history.last(20),
-
editable: contract.editable?,
-
can_submit: contract.can_submit?,
-
can_activate: contract.can_activate?,
-
can_archive: ::Legal::ContractPolicy.new(current_user, contract).archive?,
-
can_unarchive: ::Legal::ContractPolicy.new(current_user, contract).unarchive?
-
)
-
end
-
-
data
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Legal
-
class DashboardController < BaseController
-
# GET /api/v1/legal/dashboard
-
def show
-
render json: {
-
data: {
-
third_parties: third_party_stats,
-
contracts: contract_stats,
-
approvals: approval_stats,
-
expiring_soon: expiring_contracts
-
}
-
}
-
end
-
-
private
-
-
def third_party_stats
-
base = ::Legal::ThirdParty.where(organization_id: current_organization.id)
-
-
{
-
total: base.count,
-
active: base.active.count,
-
inactive: base.inactive.count,
-
blocked: base.blocked.count,
-
by_type: {
-
providers: base.providers.count,
-
clients: base.clients.count,
-
contractors: base.contractors.count,
-
partners: base.partners.count,
-
other: base.where(third_party_type: "other").count
-
}
-
}
-
end
-
-
def contract_stats
-
base = ::Legal::Contract.where(organization_id: current_organization.id)
-
current_year = Time.current.year
-
-
{
-
total: base.count,
-
draft: base.draft.count,
-
pending_approval: base.pending_approval.count,
-
approved: base.approved.count,
-
active: base.active.count,
-
expired: base.expired.count,
-
rejected: base.rejected.count,
-
by_type: ::Legal::Contract::TYPES.each_with_object({}) { |t, h|
-
h[t] = base.by_type(t).count
-
},
-
created_this_year: base.where(:created_at.gte => Date.new(current_year, 1, 1)).count,
-
total_value: {
-
active: base.active.sum(:amount).to_f,
-
pending: base.pending_approval.sum(:amount).to_f
-
}
-
}
-
end
-
-
def approval_stats
-
base = ::Legal::Contract.where(organization_id: current_organization.id)
-
-
pending = base.pending_approval.select { |c| c.can_approve?(current_user) }
-
-
{
-
pending_my_approval: pending.count,
-
pending_total: base.pending_approval.count,
-
by_level: {
-
level_1: base.where(approval_level: "level_1", status: "pending_approval").count,
-
level_2: base.where(approval_level: "level_2", status: "pending_approval").count,
-
level_3: base.where(approval_level: "level_3", status: "pending_approval").count,
-
level_4: base.where(approval_level: "level_4", status: "pending_approval").count
-
}
-
}
-
end
-
-
def expiring_contracts
-
::Legal::Contract
-
.where(organization_id: current_organization.id)
-
.active
-
.where(:end_date.lte => Date.current + 30)
-
.where(:end_date.gte => Date.current)
-
.order(end_date: :asc)
-
.limit(10)
-
.map do |c|
-
{
-
id: c.uuid,
-
contract_number: c.contract_number,
-
title: c.title,
-
third_party: c.third_party&.display_name,
-
end_date: c.end_date,
-
days_until_expiry: c.days_until_expiry,
-
amount: c.amount.to_f
-
}
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Legal
-
class ThirdPartiesController < BaseController
-
before_action :set_third_party, only: %i[show update destroy activate deactivate block]
-
-
# GET /api/v1/legal/third_parties
-
def index
-
authorize ::Legal::ThirdParty
-
-
third_parties = policy_scope(::Legal::ThirdParty)
-
.order(created_at: :desc)
-
-
# Filters
-
third_parties = third_parties.by_type(params[:type]) if params[:type].present?
-
third_parties = third_parties.where(status: params[:status]) if params[:status].present?
-
third_parties = third_parties.where(person_type: params[:person_type]) if params[:person_type].present?
-
third_parties = third_parties.search(params[:search]) if params[:search].present?
-
-
# Pagination
-
page = (params[:page] || 1).to_i
-
per_page = (params[:per_page] || 20).to_i
-
total = third_parties.count
-
third_parties = third_parties.skip((page - 1) * per_page).limit(per_page)
-
-
render json: {
-
data: third_parties.map { |tp| third_party_json(tp) },
-
meta: {
-
current_page: page,
-
per_page: per_page,
-
total_count: total,
-
total_pages: (total.to_f / per_page).ceil
-
}
-
}
-
end
-
-
# GET /api/v1/legal/third_parties/:id
-
def show
-
authorize @third_party
-
render json: { data: third_party_json(@third_party, detailed: true) }
-
end
-
-
# POST /api/v1/legal/third_parties
-
def create
-
authorize ::Legal::ThirdParty
-
-
@third_party = ::Legal::ThirdParty.new(third_party_params)
-
@third_party.organization = current_organization
-
@third_party.created_by = current_user
-
-
if @third_party.save
-
render json: { data: third_party_json(@third_party) }, status: :created
-
else
-
render json: { errors: @third_party.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /api/v1/legal/third_parties/:id
-
def update
-
authorize @third_party
-
-
if @third_party.update(third_party_params)
-
render json: { data: third_party_json(@third_party) }
-
else
-
render json: { errors: @third_party.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/legal/third_parties/:id
-
def destroy
-
authorize @third_party
-
-
if @third_party.contracts.any?
-
render json: { error: "No se puede eliminar un tercero con contratos asociados" }, status: :unprocessable_entity
-
elsif @third_party.destroy
-
render json: { message: "Tercero eliminado correctamente" }
-
else
-
render json: { errors: @third_party.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# POST /api/v1/legal/third_parties/:id/activate
-
def activate
-
authorize @third_party, :update?
-
-
@third_party.activate!
-
render json: { data: third_party_json(@third_party), message: "Tercero activado" }
-
end
-
-
# POST /api/v1/legal/third_parties/:id/deactivate
-
def deactivate
-
authorize @third_party, :update?
-
-
@third_party.deactivate!
-
render json: { data: third_party_json(@third_party), message: "Tercero desactivado" }
-
end
-
-
# POST /api/v1/legal/third_parties/:id/block
-
def block
-
authorize @third_party, :update?
-
-
reason = params[:reason]
-
@third_party.block!(reason: reason)
-
render json: { data: third_party_json(@third_party), message: "Tercero bloqueado" }
-
end
-
-
private
-
-
def set_third_party
-
@third_party = ::Legal::ThirdParty.find_by(uuid: params[:id])
-
render json: { error: "Tercero no encontrado" }, status: :not_found unless @third_party
-
end
-
-
def third_party_params
-
params.require(:third_party).permit(
-
:third_party_type, :person_type, :status,
-
:identification_type, :identification_number, :verification_digit,
-
:business_name, :trade_name, :first_name, :last_name,
-
:email, :phone, :mobile, :website,
-
:address, :city, :state, :postal_code, :country,
-
:legal_rep_name, :legal_rep_id_type, :legal_rep_id_number, :legal_rep_id_city,
-
:legal_rep_email, :legal_rep_phone,
-
:bank_name, :bank_account_type, :bank_account_number,
-
:industry, :notes, :tax_regime,
-
tags: [], tax_responsibilities: []
-
)
-
end
-
-
def third_party_json(third_party, detailed: false)
-
data = {
-
id: third_party.uuid,
-
code: third_party.code,
-
third_party_type: third_party.third_party_type,
-
type_label: third_party.type_label,
-
person_type: third_party.person_type,
-
status: third_party.status,
-
status_label: third_party.status_label,
-
display_name: third_party.display_name,
-
identification_type: third_party.identification_type,
-
identification_number: third_party.identification_number,
-
full_identification: third_party.full_identification,
-
email: third_party.email,
-
phone: third_party.phone,
-
city: third_party.city,
-
country: third_party.country,
-
contracts_count: third_party.contracts.count,
-
created_at: third_party.created_at,
-
updated_at: third_party.updated_at
-
}
-
-
if detailed
-
data.merge!(
-
verification_digit: third_party.verification_digit,
-
business_name: third_party.business_name,
-
trade_name: third_party.trade_name,
-
first_name: third_party.first_name,
-
last_name: third_party.last_name,
-
mobile: third_party.mobile,
-
website: third_party.website,
-
address: third_party.address,
-
state: third_party.state,
-
postal_code: third_party.postal_code,
-
full_address: third_party.full_address,
-
legal_rep_name: third_party.legal_rep_name,
-
legal_rep_id_type: third_party.legal_rep_id_type,
-
legal_rep_id_number: third_party.legal_rep_id_number,
-
legal_rep_id_city: third_party.legal_rep_id_city,
-
legal_rep_email: third_party.legal_rep_email,
-
legal_rep_phone: third_party.legal_rep_phone,
-
bank_name: third_party.bank_name,
-
bank_account_type: third_party.bank_account_type,
-
bank_account_number: third_party.bank_account_number,
-
industry: third_party.industry,
-
tags: third_party.tags,
-
notes: third_party.notes,
-
tax_regime: third_party.tax_regime,
-
tax_responsibilities: third_party.tax_responsibilities,
-
created_by: third_party.created_by ? {
-
id: third_party.created_by.uuid,
-
name: third_party.created_by.full_name
-
} : nil
-
)
-
end
-
-
data
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
module Legal
-
class ThirdPartyTypesController < BaseController
-
before_action :set_third_party_type, only: [:show, :update, :destroy, :toggle_active]
-
-
def index
-
authorize ::Legal::ThirdPartyType
-
-
types = current_organization.third_party_types.ordered
-
-
# Filter by active status if provided
-
types = types.active if params[:active] == "true"
-
-
render json: {
-
success: true,
-
data: types.map { |t| serialize_type(t) }
-
}
-
end
-
-
def show
-
authorize @third_party_type
-
render json: {
-
success: true,
-
data: serialize_type(@third_party_type)
-
}
-
end
-
-
def create
-
authorize ::Legal::ThirdPartyType
-
type = current_organization.third_party_types.new(type_params)
-
-
if type.save
-
render json: {
-
success: true,
-
data: serialize_type(type)
-
}, status: :created
-
else
-
render json: {
-
success: false,
-
errors: type.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
def update
-
authorize @third_party_type
-
if @third_party_type.update(type_params)
-
render json: {
-
success: true,
-
data: serialize_type(@third_party_type)
-
}
-
else
-
render json: {
-
success: false,
-
errors: @third_party_type.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
def destroy
-
authorize @third_party_type
-
unless @third_party_type.deletable?
-
return render json: {
-
success: false,
-
error: @third_party_type.is_system ? "No se pueden eliminar tipos del sistema" : "Este tipo tiene terceros asociados"
-
}, status: :unprocessable_entity
-
end
-
-
@third_party_type.destroy
-
render json: { success: true }
-
end
-
-
def toggle_active
-
authorize @third_party_type
-
@third_party_type.toggle_active!
-
render json: {
-
success: true,
-
data: serialize_type(@third_party_type)
-
}
-
end
-
-
private
-
-
def set_third_party_type
-
@third_party_type = current_organization.third_party_types.find(params[:id])
-
rescue Mongoid::Errors::DocumentNotFound
-
render json: { success: false, error: "Tipo no encontrado" }, status: :not_found
-
end
-
-
def type_params
-
params.require(:third_party_type).permit(:code, :name, :description, :color, :icon, :active, :position)
-
end
-
-
def serialize_type(type)
-
{
-
id: type.id.to_s,
-
code: type.code,
-
name: type.name,
-
description: type.description,
-
color: type.color,
-
icon: type.icon,
-
active: type.active,
-
is_system: type.is_system,
-
position: type.position,
-
deletable: type.deletable?,
-
third_parties_count: ::Legal::ThirdParty.where(
-
organization_id: type.organization_id,
-
third_party_type: type.code
-
).count,
-
created_at: type.created_at,
-
updated_at: type.updated_at
-
}
-
end
-
-
def current_organization
-
@current_organization ||= current_user.organization
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
# Global search endpoint
-
class SearchController < BaseController
-
# GET /api/v1/search?q=query&type=documents,folders&page=1&per_page=20
-
def index
-
return render json: { error: "Search query (q) is required" }, status: :bad_request if params[:q].blank?
-
-
results = search_service.search(
-
params[:q],
-
types: search_types,
-
filters: search_filters,
-
page: params[:page]&.to_i || 1,
-
per_page: params[:per_page]&.to_i || 20
-
)
-
-
render json: {
-
data: format_results(results),
-
meta: {
-
query: params[:q],
-
total: results.total_count,
-
page: results.current_page,
-
per_page: results.per_page,
-
total_pages: results.total_pages
-
}
-
}
-
end
-
-
private
-
-
def search_service
-
@search_service ||= Search::SearchService.new(
-
organization: current_organization,
-
user: current_user
-
)
-
end
-
-
def search_types
-
return nil if params[:type].blank?
-
-
params[:type].split(",").map(&:strip)
-
end
-
-
def search_filters
-
filters = {}
-
filters[:folder_id] = params[:folder_id] if params[:folder_id].present?
-
filters[:status] = params[:status] if params[:status].present?
-
filters[:created_after] = params[:created_after] if params[:created_after].present?
-
filters[:created_before] = params[:created_before] if params[:created_before].present?
-
filters
-
end
-
-
def format_results(results)
-
results.items.map do |item|
-
{
-
id: item.uuid,
-
type: item.class.name.demodulize.underscore,
-
title: item.respond_to?(:title) ? item.title : item.name,
-
snippet: results.snippet_for(item),
-
score: results.score_for(item),
-
created_at: item.created_at.iso8601,
-
url: item_url(item)
-
}
-
end
-
end
-
-
def item_url(item)
-
case item
-
when Content::Document
-
"/documents/#{item.uuid}"
-
when Content::Folder
-
"/folders/#{item.uuid}"
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
class TemplatesController < BaseController
-
# GET /api/v1/templates
-
# Public endpoint for listing active templates (read-only)
-
def index
-
templates = Templates::Template
-
.where(organization_id: current_user.organization_id)
-
.active
-
-
# Filter by main_category
-
if params[:main_category].present?
-
templates = templates.where(main_category: params[:main_category])
-
end
-
-
# Filter by category
-
if params[:category].present?
-
templates = templates.where(category: params[:category])
-
end
-
-
# Filter by module_type
-
if params[:module_type].present?
-
templates = templates.where(module_type: params[:module_type])
-
end
-
-
# Search by name
-
if params[:q].present?
-
templates = templates.where(name: /#{Regexp.escape(params[:q])}/i)
-
end
-
-
templates = templates.order(name: :asc)
-
-
render json: {
-
success: true,
-
data: templates.map { |t| template_json(t) }
-
}
-
end
-
-
# GET /api/v1/templates/:id
-
def show
-
template = Templates::Template.find_by(
-
uuid: params[:id],
-
organization_id: current_user.organization_id,
-
status: "active"
-
)
-
-
if template
-
render json: {
-
success: true,
-
data: template_json(template, detailed: true)
-
}
-
else
-
render json: { error: "Template no encontrado" }, status: :not_found
-
end
-
end
-
-
# GET /api/v1/templates/:id/third_party_requirements
-
def third_party_requirements
-
template = Templates::Template.find_by(
-
uuid: params[:id],
-
organization_id: current_user.organization_id,
-
status: "active"
-
)
-
-
unless template
-
render json: { error: "Template no encontrado" }, status: :not_found
-
return
-
end
-
-
render json: {
-
data: {
-
template_id: template.uuid,
-
template_name: template.name,
-
default_third_party_type: template.default_third_party_type,
-
suggested_person_type: template.suggested_person_type,
-
required_fields: template.required_third_party_fields,
-
uses_third_party: template.uses_third_party_variables?,
-
variables: template.variables,
-
variables_count: template.variables&.count || 0
-
}
-
}
-
end
-
-
private
-
-
def template_json(template, detailed: false)
-
json = {
-
id: template.uuid,
-
name: template.name,
-
description: template.description,
-
module_type: template.module_type,
-
main_category: template.main_category,
-
category: template.category,
-
default_third_party_type: template.default_third_party_type,
-
uses_third_party: template.uses_third_party_variables?,
-
signatories_count: template.signatories.count,
-
sequential_signing: template.sequential_signing != false,
-
variables_count: template.variables&.count || 0
-
}
-
-
if detailed
-
json[:variables] = template.variables
-
json[:signatories] = template.signatories.by_position.map do |sig|
-
{
-
id: sig.uuid,
-
label: sig.label,
-
role: sig.role,
-
signatory_type_code: sig.signatory_type_code,
-
required: sig.required,
-
position: sig.position
-
}
-
end
-
end
-
-
json
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Api
-
module V1
-
class UsersController < BaseController
-
before_action :set_user, only: [:show]
-
-
def index
-
authorize Identity::User
-
users = policy_scope(Identity::User).enabled
-
-
render json: {
-
data: users.map { |user| user_response(user) }
-
}, status: :ok
-
end
-
-
def show
-
authorize @user
-
-
render json: {
-
data: user_response(@user)
-
}, status: :ok
-
end
-
-
private
-
-
def set_user
-
@user = Identity::User.find(params[:id])
-
end
-
-
def user_response(user)
-
{
-
id: user.id.to_s,
-
email: user.email,
-
first_name: user.first_name,
-
last_name: user.last_name,
-
full_name: user.full_name,
-
employee_id: user.employee_id,
-
department: user.department,
-
title: user.title,
-
roles: user.role_names,
-
active: user.active,
-
organization_id: user.organization_id&.to_s,
-
created_at: user.created_at.iso8601,
-
updated_at: user.updated_at.iso8601
-
}
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class ApplicationController < ActionController::API
-
include Pundit::Authorization
-
-
before_action :set_request_context
-
-
rescue_from Pundit::NotAuthorizedError, with: :handle_unauthorized
-
rescue_from Mongoid::Errors::DocumentNotFound, with: :handle_not_found
-
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
-
-
protected
-
-
def set_request_context
-
Current.request_id = request.request_id
-
Current.ip_address = request.remote_ip
-
Current.user_agent = request.user_agent
-
end
-
-
def render_json(data, status: :ok, meta: {})
-
response = { data: data }
-
response[:meta] = meta if meta.present?
-
render json: response, status: status
-
end
-
-
def render_error(message, status: :unprocessable_entity, errors: [])
-
render json: {
-
error: message,
-
errors: Array(errors)
-
}, status: status
-
end
-
-
def render_errors(errors, status: :unprocessable_entity)
-
render json: {
-
errors: Array(errors)
-
}, status: status
-
end
-
-
private
-
-
def handle_unauthorized(exception)
-
render_error(
-
"You are not authorized to perform this action",
-
status: :forbidden,
-
errors: [exception.message]
-
)
-
end
-
-
def handle_not_found(exception)
-
render_error(
-
"Resource not found",
-
status: :not_found,
-
errors: [exception.message]
-
)
-
end
-
-
def handle_bad_request(exception)
-
render_error(
-
"Bad request",
-
status: :bad_request,
-
errors: [exception.message]
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
class FrontendController < ActionController::Base
-
def index
-
render file: Rails.public_path.join("index.html"), layout: false
-
end
-
end
-
# frozen_string_literal: true
-
-
class ApplicationJob < ActiveJob::Base
-
# Retry configuration
-
retry_on StandardError, wait: :polynomially_longer, attempts: 3
-
-
# Discard jobs when document not found (Mongoid equivalent)
-
discard_on Mongoid::Errors::DocumentNotFound
-
-
# Queue priority
-
queue_with_priority 10
-
-
# Logging
-
around_perform do |job, block|
-
Rails.logger.tagged("Job:#{job.class.name}", "JID:#{job.job_id}") do
-
start_time = Time.current
-
Rails.logger.info("Started job with args: #{job.arguments.inspect}")
-
-
block.call
-
-
duration = Time.current - start_time
-
Rails.logger.info("Completed job in #{duration.round(2)}s")
-
end
-
rescue StandardError => e
-
Rails.logger.error("Job failed: #{e.message}")
-
Rails.logger.error(e.backtrace&.first(10)&.join("\n"))
-
raise
-
end
-
-
protected
-
-
def log_info(message)
-
Rails.logger.info("[#{self.class.name}] #{message}")
-
end
-
-
def log_error(message)
-
Rails.logger.error("[#{self.class.name}] #{message}")
-
end
-
end
-
# frozen_string_literal: true
-
-
# Handles retention-related notifications
-
# Supports warnings, pending actions, and legal hold notifications
-
#
-
# rubocop:disable Metrics/ClassLength
-
class RetentionNotificationJob < ApplicationJob
-
queue_as :default
-
-
# @param notification_type [String] Type of notification
-
# @param schedule_id [String] ID of the retention schedule
-
# @param options [Hash] Additional notification options
-
def perform(notification_type, schedule_id, **options)
-
schedule = Retention::RetentionSchedule.find(schedule_id)
-
-
case notification_type
-
when "warning"
-
handle_warning_notification(schedule, options)
-
when "pending_action"
-
handle_pending_action_notification(schedule, options)
-
when "archived"
-
handle_archived_notification(schedule, options)
-
when "expired"
-
handle_expired_notification(schedule, options)
-
when "legal_hold_placed"
-
handle_legal_hold_placed_notification(schedule, options)
-
when "legal_hold_released"
-
handle_legal_hold_released_notification(schedule, options)
-
else
-
Rails.logger.warn "[RetentionNotification] Unknown notification type: #{notification_type}"
-
end
-
rescue Mongoid::Errors::DocumentNotFound
-
Rails.logger.error "[RetentionNotification] Schedule not found: #{schedule_id}"
-
end
-
-
private
-
-
def handle_warning_notification(schedule, options)
-
days = options[:days_until_expiration]
-
document = schedule.document
-
-
# Notify document owner/creator
-
notify_document_stakeholders(
-
schedule,
-
subject: "[Retention Warning] Document expiring soon: #{document.title}",
-
body: build_warning_message(schedule, days)
-
)
-
-
# Notify records managers
-
notify_records_managers(
-
schedule.organization,
-
subject: "[Retention Warning] Document requires attention",
-
body: build_warning_message(schedule, days)
-
)
-
-
Rails.logger.info "[RetentionNotification] Warning sent for #{document.title}"
-
end
-
-
def handle_pending_action_notification(schedule, options)
-
action = options[:action]
-
days_overdue = options[:days_overdue]
-
document = schedule.document
-
-
notify_records_managers(
-
schedule.organization,
-
subject: "[Retention Action Required] Document ready for #{action}: #{document.title}",
-
body: build_pending_action_message(schedule, action, days_overdue)
-
)
-
-
Rails.logger.info "[RetentionNotification] Pending action notification for #{document.title}"
-
end
-
-
def handle_archived_notification(schedule, _options)
-
document = schedule.document
-
-
notify_document_stakeholders(
-
schedule,
-
subject: "[Retention] Document archived: #{document.title}",
-
body: build_archived_message(schedule)
-
)
-
-
Rails.logger.info "[RetentionNotification] Archive notification for #{document.title}"
-
end
-
-
def handle_expired_notification(schedule, _options)
-
document = schedule.document
-
-
notify_document_stakeholders(
-
schedule,
-
subject: "[Retention] Document expired: #{document.title}",
-
body: build_expired_message(schedule)
-
)
-
-
Rails.logger.info "[RetentionNotification] Expiration notification for #{document.title}"
-
end
-
-
def handle_legal_hold_placed_notification(schedule, options)
-
hold_name = options[:hold_name]
-
document = schedule.document
-
-
notify_document_stakeholders(
-
schedule,
-
subject: "[Legal Hold] Document placed on hold: #{document.title}",
-
body: build_legal_hold_placed_message(schedule, hold_name)
-
)
-
-
notify_legal_team(
-
schedule.organization,
-
subject: "[Legal Hold] Document preservation active",
-
body: build_legal_hold_placed_message(schedule, hold_name)
-
)
-
-
Rails.logger.info "[RetentionNotification] Legal hold placed for #{document.title}"
-
end
-
-
def handle_legal_hold_released_notification(schedule, options)
-
hold_name = options[:hold_name]
-
document = schedule.document
-
-
notify_document_stakeholders(
-
schedule,
-
subject: "[Legal Hold Released] #{document.title}",
-
body: build_legal_hold_released_message(schedule, hold_name)
-
)
-
-
Rails.logger.info "[RetentionNotification] Legal hold released for #{document.title}"
-
end
-
-
# Notification delivery methods
-
-
def notify_document_stakeholders(schedule, subject:, body:)
-
document = schedule.document
-
-
# Notify creator
-
deliver_notification(document.created_by, subject, body) if document.created_by
-
-
# Notify last modifier if different
-
return unless document.last_modified_by && document.last_modified_by != document.created_by
-
-
deliver_notification(document.last_modified_by, subject, body)
-
end
-
-
def notify_records_managers(organization, subject:, body:)
-
# Notify users with records_manager or admin role
-
["records_manager", "admin"].each do |role_name|
-
# rubocop:disable Rails/FindEach
-
organization.users.joins(:roles).where(identity_roles: { name: role_name }).each do |user|
-
deliver_notification(user, subject, body)
-
end
-
# rubocop:enable Rails/FindEach
-
rescue StandardError
-
next
-
end
-
end
-
-
def notify_legal_team(organization, subject:, body:)
-
# rubocop:disable Rails/FindEach
-
organization.users.joins(:roles).where(identity_roles: { name: "legal" }).each do |user|
-
deliver_notification(user, subject, body)
-
end
-
# rubocop:enable Rails/FindEach
-
rescue StandardError => e
-
Rails.logger.warn "[RetentionNotification] Could not notify legal team: #{e.message}"
-
end
-
-
def deliver_notification(user, subject, body)
-
return unless user
-
-
Rails.logger.info "[RetentionNotification] Delivering to #{user.email}: #{subject}"
-
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:system],
-
action: "notification_sent",
-
target: user,
-
actor: nil,
-
metadata: {
-
subject: subject,
-
body_preview: body.to_s[0..100]
-
},
-
tags: ["notification", "retention"]
-
)
-
end
-
-
# Message builders
-
-
def build_warning_message(schedule, days)
-
<<~MESSAGE
-
Document Retention Warning
-
-
Document: #{schedule.document.title}
-
Policy: #{schedule.policy&.name || "N/A"}
-
Expiration Date: #{schedule.expiration_date&.strftime("%Y-%m-%d")}
-
Days Remaining: #{days}
-
-
Action Required: #{schedule.policy&.expiration_action&.titleize || "Review"}
-
-
Please review this document before the retention period expires.
-
MESSAGE
-
end
-
-
def build_pending_action_message(schedule, action, days_overdue)
-
<<~MESSAGE
-
Document Ready for Retention Action
-
-
Document: #{schedule.document.title}
-
Policy: #{schedule.policy&.name || "N/A"}
-
Expiration Date: #{schedule.expiration_date&.strftime("%Y-%m-%d")}
-
Days Overdue: #{days_overdue}
-
-
Required Action: #{action&.titleize || "Review"}
-
-
This document has exceeded its retention period and requires action.
-
MESSAGE
-
end
-
-
def build_archived_message(schedule)
-
<<~MESSAGE
-
Document Archived
-
-
Document: #{schedule.document.title}
-
Archived Date: #{schedule.action_date&.strftime("%Y-%m-%d %H:%M")}
-
Policy: #{schedule.policy&.name || "N/A"}
-
-
This document has been archived per retention policy.
-
The document remains accessible in read-only mode.
-
MESSAGE
-
end
-
-
def build_expired_message(schedule)
-
<<~MESSAGE
-
Document Expired
-
-
Document: #{schedule.document.title}
-
Expiration Date: #{schedule.action_date&.strftime("%Y-%m-%d %H:%M")}
-
Policy: #{schedule.policy&.name || "N/A"}
-
-
This document has been marked as expired per retention policy.
-
The document is preserved but flagged as expired.
-
MESSAGE
-
end
-
-
def build_legal_hold_placed_message(schedule, hold_name)
-
<<~MESSAGE
-
Legal Hold Placed on Document
-
-
Document: #{schedule.document.title}
-
Hold Name: #{hold_name}
-
Effective Date: #{Time.current.strftime("%Y-%m-%d %H:%M")}
-
-
IMPORTANT: This document is now under legal hold.
-
- No modifications are permitted
-
- No archival or deletion actions will be performed
-
- The document must be preserved in its current state
-
-
Contact your legal department for questions.
-
MESSAGE
-
end
-
-
def build_legal_hold_released_message(schedule, hold_name)
-
<<~MESSAGE
-
Legal Hold Released
-
-
Document: #{schedule.document.title}
-
Hold Name: #{hold_name}
-
Release Date: #{Time.current.strftime("%Y-%m-%d %H:%M")}
-
-
The legal hold on this document has been released.
-
Normal retention processing will resume.
-
MESSAGE
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
# frozen_string_literal: true
-
-
# Processes retention schedules on a scheduled basis
-
# - Sends warnings for approaching expirations
-
# - Marks documents for pending action when expired
-
# - Does NOT physically delete any documents
-
#
-
# This job should be run daily via cron or similar scheduler
-
#
-
class RetentionProcessorJob < ApplicationJob
-
queue_as :low
-
-
# @param organization_id [String] Optional - process only for specific organization
-
def perform(organization_id = nil)
-
if organization_id
-
process_organization(Identity::Organization.find(organization_id))
-
else
-
process_all_organizations
-
end
-
end
-
-
private
-
-
def process_all_organizations
-
Identity::Organization.each do |org|
-
process_organization(org)
-
rescue StandardError => e
-
Rails.logger.error "[RetentionProcessor] Error processing org #{org.id}: #{e.message}"
-
end
-
-
# Also process schedules without organization (shouldn't happen, but safety)
-
process_global_schedules
-
end
-
-
def process_organization(organization)
-
Rails.logger.info "[RetentionProcessor] Processing organization: #{organization.name}"
-
-
stats = {
-
warnings_sent: 0,
-
marked_pending: 0,
-
skipped_held: 0
-
}
-
-
# Process warning notifications
-
stats[:warnings_sent] = process_warnings(organization)
-
-
# Process expired documents
-
result = process_expirations(organization)
-
stats[:marked_pending] = result[:marked]
-
stats[:skipped_held] = result[:skipped]
-
-
log_processing_complete(organization, stats)
-
end
-
-
def process_global_schedules
-
Retention::RetentionSchedule
-
.where(organization_id: nil)
-
.needs_warning
-
.each { |schedule| send_warning(schedule) }
-
-
Retention::RetentionSchedule
-
.where(organization_id: nil)
-
.past_expiration
-
.each { |schedule| mark_for_action(schedule) }
-
end
-
-
def process_warnings(organization)
-
count = 0
-
-
# rubocop:disable Rails/FindEach
-
Retention::RetentionSchedule
-
.needs_warning
-
.where(organization_id: organization.id)
-
.each do |schedule|
-
# rubocop:enable Rails/FindEach
-
next if schedule.under_legal_hold?
-
-
send_warning(schedule)
-
count += 1
-
rescue StandardError => e
-
Rails.logger.error "[RetentionProcessor] Warning error for schedule #{schedule.id}: #{e.message}"
-
end
-
-
count
-
end
-
-
def process_expirations(organization)
-
marked = 0
-
skipped = 0
-
-
# rubocop:disable Rails/FindEach
-
Retention::RetentionSchedule
-
.past_expiration
-
.where(organization_id: organization.id)
-
.each do |schedule|
-
# rubocop:enable Rails/FindEach
-
if schedule.under_legal_hold?
-
skipped += 1
-
log_skipped_due_to_hold(schedule)
-
next
-
end
-
-
mark_for_action(schedule)
-
marked += 1
-
rescue StandardError => e
-
Rails.logger.error "[RetentionProcessor] Expiration error for schedule #{schedule.id}: #{e.message}"
-
end
-
-
{ marked: marked, skipped: skipped }
-
end
-
-
def send_warning(schedule)
-
schedule.mark_warning!
-
-
# Queue notification
-
RetentionNotificationJob.perform_later(
-
"warning",
-
schedule.id.to_s,
-
days_until_expiration: schedule.days_until_expiration
-
)
-
-
Rails.logger.info(
-
"[RetentionProcessor] Warning sent for document #{schedule.document_id} " \
-
"(expires in #{schedule.days_until_expiration} days)"
-
)
-
end
-
-
def mark_for_action(schedule)
-
schedule.mark_pending!
-
-
# Queue notification for pending action
-
RetentionNotificationJob.perform_later(
-
"pending_action",
-
schedule.id.to_s,
-
action: schedule.policy&.expiration_action,
-
days_overdue: schedule.days_overdue
-
)
-
-
Rails.logger.info(
-
"[RetentionProcessor] Marked pending action for document #{schedule.document_id} " \
-
"(overdue by #{schedule.days_overdue} days)"
-
)
-
end
-
-
def log_skipped_due_to_hold(schedule)
-
Rails.logger.info(
-
"[RetentionProcessor] Skipped document #{schedule.document_id} - under legal hold"
-
)
-
-
# Record in audit
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:system],
-
action: "retention_skipped_legal_hold",
-
target: schedule.document,
-
actor: nil,
-
metadata: {
-
schedule_id: schedule.id.to_s,
-
expiration_date: schedule.expiration_date&.iso8601,
-
active_holds: schedule.legal_holds.active.count
-
},
-
tags: ["retention", "legal_hold", "skipped"]
-
)
-
end
-
-
def log_processing_complete(organization, stats)
-
Rails.logger.info(
-
"[RetentionProcessor] Completed for #{organization.name}: " \
-
"#{stats[:warnings_sent]} warnings, #{stats[:marked_pending]} marked pending, " \
-
"#{stats[:skipped_held]} skipped (held)"
-
)
-
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:system],
-
action: "retention_processing_complete",
-
target: organization,
-
actor: nil,
-
metadata: stats,
-
tags: ["retention", "processing", "batch"]
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Checks if a workflow task has breached its SLA
-
# Scheduled to run at the task's due time
-
#
-
class SlaCheckJob < ApplicationJob
-
queue_as :default
-
-
# @param task_id [String] ID of the workflow task to check
-
def perform(task_id)
-
task = Workflow::WorkflowTask.find(task_id)
-
-
# Skip if task is already completed or cancelled
-
return if task.completed? || task.status == Workflow::WorkflowTask::STATUS_CANCELLED
-
-
handle_sla_breach(task) if task.overdue?
-
rescue Mongoid::Errors::DocumentNotFound
-
Rails.logger.warn "[SlaCheckJob] Task not found: #{task_id}"
-
end
-
-
private
-
-
# rubocop:disable Metrics/MethodLength
-
def handle_sla_breach(task)
-
# Update task status
-
task.update!(status: Workflow::WorkflowTask::STATUS_OVERDUE)
-
-
# Escalate the task
-
task.escalate!(reason: "SLA deadline breached")
-
-
# Send breach notification
-
WorkflowNotificationJob.perform_later(
-
"sla_breached",
-
task.id.to_s
-
)
-
-
# Record audit event
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:workflow],
-
action: "sla_breached",
-
target: task,
-
actor: nil, # System action
-
metadata: {
-
workflow_instance_id: task.instance_id.to_s,
-
state: task.state,
-
due_at: task.due_at&.iso8601,
-
assigned_role: task.assigned_role,
-
assignee_id: task.assignee_id&.to_s
-
},
-
tags: ["workflow", "sla", "breached"]
-
)
-
-
Rails.logger.warn(
-
"[SlaCheckJob] SLA breached for task #{task.id} " \
-
"(state: #{task.state}, due: #{task.due_at})"
-
)
-
end
-
# rubocop:enable Metrics/MethodLength
-
end
-
# frozen_string_literal: true
-
-
# Sends SLA warning notifications before a task becomes overdue
-
# Typically scheduled at 75% and 50% of the SLA period
-
#
-
class SlaWarningJob < ApplicationJob
-
queue_as :default
-
-
# @param task_id [String] ID of the workflow task
-
# @param percentage_remaining [Integer] Percentage of SLA time remaining (e.g., 75, 50, 25)
-
def perform(task_id, percentage_remaining)
-
task = Workflow::WorkflowTask.find(task_id)
-
-
# Skip if task is already completed, cancelled, or overdue
-
return if task.completed?
-
return if task.status == Workflow::WorkflowTask::STATUS_CANCELLED
-
return if task.overdue?
-
-
# Send warning notification
-
WorkflowNotificationJob.perform_later(
-
"sla_warning",
-
task.id.to_s,
-
percentage_remaining: percentage_remaining
-
)
-
-
Rails.logger.info(
-
"[SlaWarningJob] Warning sent for task #{task.id} " \
-
"(#{percentage_remaining}% time remaining)"
-
)
-
rescue Mongoid::Errors::DocumentNotFound
-
Rails.logger.warn "[SlaWarningJob] Task not found: #{task_id}"
-
end
-
end
-
# frozen_string_literal: true
-
-
# Handles workflow-related notifications
-
# Supports various notification types: transitions, escalations, SLA warnings
-
#
-
# rubocop:disable Metrics/ClassLength
-
class WorkflowNotificationJob < ApplicationJob
-
queue_as :default
-
-
# @param notification_type [String] Type of notification
-
# @param resource_id [String] ID of the workflow instance or task
-
# @param options [Hash] Additional notification options
-
def perform(notification_type, resource_id, **options)
-
case notification_type
-
when "transition"
-
handle_transition_notification(resource_id, options)
-
when "task_created"
-
handle_task_created_notification(resource_id, options)
-
when "task_escalated"
-
handle_task_escalated_notification(resource_id, options)
-
when "cancelled"
-
handle_cancellation_notification(resource_id, options)
-
when "sla_warning"
-
handle_sla_warning_notification(resource_id, options)
-
when "sla_breached"
-
handle_sla_breached_notification(resource_id, options)
-
else
-
Rails.logger.warn "Unknown workflow notification type: #{notification_type}"
-
end
-
end
-
-
private
-
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
-
def handle_transition_notification(instance_id, options)
-
instance = Workflow::WorkflowInstance.find(instance_id)
-
from_state = options[:from_state]
-
to_state = options[:to_state]
-
actor = Identity::User.find(options[:actor_id])
-
-
# Notify stakeholders about the state change
-
notify_stakeholders(
-
instance,
-
subject: "Workflow transitioned: #{from_state} → #{to_state}",
-
body: build_transition_message(instance, from_state, to_state, actor)
-
)
-
-
# If entering a new state with assigned role, notify that role
-
if (step = instance.definition.step_for(to_state)) && step["assigned_role"] && step["assigned_role"]
-
notify_role(
-
instance.organization,
-
step["assigned_role"],
-
subject: "New task: #{instance.definition.name} - #{to_state}",
-
body: build_new_task_message(instance, to_state)
-
)
-
end
-
-
Rails.logger.info(
-
"[WorkflowNotification] Transition: #{instance.definition.name} " \
-
"#{from_state} → #{to_state} by #{actor.email}"
-
)
-
rescue Mongoid::Errors::DocumentNotFound => e
-
Rails.logger.error "[WorkflowNotification] Resource not found: #{e.message}"
-
end
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
-
-
def handle_task_created_notification(task_id, options)
-
task = Workflow::WorkflowTask.find(task_id)
-
assigned_role = options[:assigned_role]
-
state = options[:state]
-
-
return unless assigned_role
-
-
notify_role(
-
task.organization,
-
assigned_role,
-
subject: "New workflow task available: #{state}",
-
body: build_task_available_message(task)
-
)
-
-
Rails.logger.info(
-
"[WorkflowNotification] Task created: #{task.id} " \
-
"assigned to role: #{assigned_role}"
-
)
-
rescue Mongoid::Errors::DocumentNotFound => e
-
Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
-
end
-
-
def handle_task_escalated_notification(task_id, options)
-
task = Workflow::WorkflowTask.find(task_id)
-
escalation_level = options[:escalation_level]
-
reason = options[:reason]
-
-
# Notify managers/admins about escalation
-
notify_managers(
-
task.organization,
-
subject: "[ESCALATION Level #{escalation_level}] Workflow task requires attention",
-
body: build_escalation_message(task, escalation_level, reason)
-
)
-
-
Rails.logger.info(
-
"[WorkflowNotification] Task escalated: #{task.id} " \
-
"level: #{escalation_level}"
-
)
-
rescue Mongoid::Errors::DocumentNotFound => e
-
Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
-
end
-
-
def handle_cancellation_notification(instance_id, options)
-
instance = Workflow::WorkflowInstance.find(instance_id)
-
actor = Identity::User.find(options[:actor_id])
-
reason = options[:reason]
-
-
notify_stakeholders(
-
instance,
-
subject: "Workflow cancelled: #{instance.definition.name}",
-
body: build_cancellation_message(instance, actor, reason)
-
)
-
-
Rails.logger.info(
-
"[WorkflowNotification] Workflow cancelled: #{instance.id} " \
-
"by #{actor.email}, reason: #{reason}"
-
)
-
rescue Mongoid::Errors::DocumentNotFound => e
-
Rails.logger.error "[WorkflowNotification] Resource not found: #{e.message}"
-
end
-
-
# rubocop:disable Metrics/MethodLength
-
def handle_sla_warning_notification(task_id, options)
-
task = Workflow::WorkflowTask.find(task_id)
-
percentage_remaining = options[:percentage_remaining]
-
-
return if task.completed? || task.status == Workflow::WorkflowTask::STATUS_CANCELLED
-
-
# Notify assignee if claimed, otherwise notify role
-
if task.assignee
-
notify_user(
-
task.assignee,
-
subject: "[SLA Warning] Task due soon: #{task.state}",
-
body: build_sla_warning_message(task, percentage_remaining)
-
)
-
else
-
notify_role(
-
task.organization,
-
task.assigned_role,
-
subject: "[SLA Warning] Unclaimed task due soon: #{task.state}",
-
body: build_sla_warning_message(task, percentage_remaining)
-
)
-
end
-
-
Rails.logger.info(
-
"[WorkflowNotification] SLA warning: #{task.id} " \
-
"#{percentage_remaining}% time remaining"
-
)
-
rescue Mongoid::Errors::DocumentNotFound => e
-
Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
-
end
-
# rubocop:enable Metrics/MethodLength
-
-
def handle_sla_breached_notification(task_id, _options)
-
task = Workflow::WorkflowTask.find(task_id)
-
-
return if task.completed? || task.status == Workflow::WorkflowTask::STATUS_CANCELLED
-
-
# Notify managers about SLA breach
-
notify_managers(
-
task.organization,
-
subject: "[SLA BREACH] Task overdue: #{task.state}",
-
body: build_sla_breach_message(task)
-
)
-
-
# Also notify assignee if claimed
-
if task.assignee
-
notify_user(
-
task.assignee,
-
subject: "[SLA BREACH] Your task is overdue: #{task.state}",
-
body: build_sla_breach_message(task)
-
)
-
end
-
-
Rails.logger.warn(
-
"[WorkflowNotification] SLA breached: #{task.id}"
-
)
-
rescue Mongoid::Errors::DocumentNotFound => e
-
Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
-
end
-
-
# Notification delivery methods
-
# These would integrate with your notification system (email, in-app, etc.)
-
-
def notify_stakeholders(instance, subject:, body:)
-
# Stakeholders: initiator + anyone in state history
-
stakeholder_ids = [instance.initiated_by_id.to_s]
-
instance.state_history.each do |entry|
-
stakeholder_ids << entry["actor_id"] if entry["actor_id"]
-
end
-
-
stakeholder_ids.uniq.each do |user_id|
-
user = Identity::User.find(user_id)
-
deliver_notification(user, subject, body)
-
rescue Mongoid::Errors::DocumentNotFound
-
next
-
end
-
end
-
-
def notify_role(organization, role_name, subject:, body:)
-
return if role_name.blank?
-
-
users_with_role = organization.users.joins(:roles).where(
-
identity_roles: { name: role_name }
-
)
-
-
users_with_role.each do |user|
-
deliver_notification(user, subject, body)
-
end
-
end
-
-
def notify_managers(organization, subject:, body:)
-
# Notify admin and manager roles
-
["admin", "manager"].each do |role_name|
-
notify_role(organization, role_name, subject: subject, body: body)
-
end
-
end
-
-
def notify_user(user, subject:, body:)
-
return unless user
-
-
deliver_notification(user, subject, body)
-
end
-
-
def deliver_notification(user, subject, body)
-
# Placeholder for actual notification delivery
-
# Could send email, create in-app notification, push notification, etc.
-
Rails.logger.info(
-
"[WorkflowNotification] Delivering to #{user.email}: #{subject}"
-
)
-
-
# Example: Create audit record of notification
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:system],
-
action: "notification_sent",
-
target: user,
-
actor: nil, # System notification
-
metadata: {
-
subject: subject,
-
body_preview: body.to_s[0..100]
-
},
-
tags: ["notification", "workflow"]
-
)
-
end
-
-
# Message builders
-
-
def build_transition_message(instance, from_state, to_state, actor)
-
<<~MESSAGE
-
Workflow: #{instance.definition.name}
-
Document: #{instance.document&.title || "N/A"}
-
-
State changed from "#{from_state}" to "#{to_state}"
-
Changed by: #{actor.full_name} (#{actor.email})
-
Time: #{Time.current.strftime("%Y-%m-%d %H:%M %Z")}
-
MESSAGE
-
end
-
-
def build_new_task_message(instance, state)
-
step = instance.definition.step_for(state)
-
sla_hours = instance.definition.sla_hours_for(state)
-
-
<<~MESSAGE
-
A new task is available for you to work on.
-
-
Workflow: #{instance.definition.name}
-
Document: #{instance.document&.title || "N/A"}
-
Current State: #{state}
-
Description: #{step["description"] || "N/A"}
-
SLA: #{sla_hours ? "#{sla_hours} hours" : "No deadline"}
-
MESSAGE
-
end
-
-
def build_task_available_message(task)
-
<<~MESSAGE
-
A workflow task is available for your role.
-
-
State: #{task.state}
-
Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z") || "No deadline"}
-
Priority: #{task.priority}
-
-
Please claim this task to begin working on it.
-
MESSAGE
-
end
-
-
def build_escalation_message(task, level, reason)
-
<<~MESSAGE
-
A workflow task has been escalated and requires management attention.
-
-
Escalation Level: #{level}
-
Reason: #{reason || "Automatic escalation due to SLA"}
-
-
Task Details:
-
State: #{task.state}
-
Assigned Role: #{task.assigned_role}
-
Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z") || "No deadline"}
-
Assignee: #{task.assignee&.full_name || "Unclaimed"}
-
-
Time Remaining: #{task.time_remaining_text}
-
MESSAGE
-
end
-
-
def build_cancellation_message(instance, actor, reason)
-
<<~MESSAGE
-
A workflow has been cancelled.
-
-
Workflow: #{instance.definition.name}
-
Document: #{instance.document&.title || "N/A"}
-
-
Cancelled by: #{actor.full_name} (#{actor.email})
-
Reason: #{reason || "No reason provided"}
-
Time: #{Time.current.strftime("%Y-%m-%d %H:%M %Z")}
-
-
Previous state: #{instance.current_state}
-
MESSAGE
-
end
-
-
def build_sla_warning_message(task, percentage_remaining)
-
<<~MESSAGE
-
A workflow task is approaching its deadline.
-
-
State: #{task.state}
-
Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z")}
-
Time Remaining: #{task.time_remaining_text} (#{percentage_remaining}%)
-
-
Please complete this task before the deadline to avoid escalation.
-
MESSAGE
-
end
-
-
def build_sla_breach_message(task)
-
<<~MESSAGE
-
[URGENT] A workflow task has exceeded its SLA deadline.
-
-
State: #{task.state}
-
Was Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z")}
-
Overdue By: #{((Time.current - task.due_at) / 1.hour).round(1)} hours
-
-
Assigned Role: #{task.assigned_role}
-
Assignee: #{task.assignee&.full_name || "Unclaimed"}
-
-
Immediate action is required.
-
MESSAGE
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
# frozen_string_literal: true
-
-
class ApplicationMailer < ActionMailer::Base
-
default from: "from@example.com"
-
layout "mailer"
-
end
-
# frozen_string_literal: true
-
-
1
module Audit
-
# rubocop:disable Metrics/ClassLength
-
1
class AuditEvent
-
1
include Mongoid::Document
-
1
include Mongoid::Timestamps::Created
-
-
# Store in separate collection for performance
-
1
store_in collection: "audit_events"
-
-
# Event identification
-
1
field :uuid, type: String
-
1
field :event_type, type: String
-
1
field :action, type: String
-
-
# Actor information (who performed the action)
-
1
field :actor_id, type: BSON::ObjectId
-
1
field :actor_type, type: String
-
1
field :actor_email, type: String
-
1
field :actor_name, type: String
-
-
# Target information (what was affected)
-
1
field :target_id, type: BSON::ObjectId
-
1
field :target_type, type: String
-
1
field :target_uuid, type: String
-
-
# Organization context
-
1
field :organization_id, type: BSON::ObjectId
-
-
# Change details
-
1
field :change_data, type: Hash, default: {}
-
1
field :previous_values, type: Hash, default: {}
-
1
field :new_values, type: Hash, default: {}
-
-
# Request context
-
1
field :request_id, type: String
-
1
field :ip_address, type: String
-
1
field :user_agent, type: String
-
1
field :session_id, type: String
-
-
# Additional metadata
-
1
field :metadata, type: Hash, default: {}
-
1
field :tags, type: Array, default: []
-
-
# Indexes for efficient querying
-
1
index({ uuid: 1 }, { unique: true })
-
1
index({ event_type: 1 })
-
1
index({ action: 1 })
-
1
index({ actor_id: 1 })
-
1
index({ target_id: 1 })
-
1
index({ target_type: 1 })
-
1
index({ organization_id: 1 })
-
1
index({ created_at: -1 })
-
1
index({ tags: 1 })
-
1
index({ target_type: 1, target_id: 1 })
-
1
index({ actor_id: 1, created_at: -1 })
-
1
index({ organization_id: 1, created_at: -1 })
-
-
# Validations
-
1
validates :uuid, presence: true, uniqueness: true
-
1
validates :event_type, presence: true
-
1
validates :action, presence: true
-
-
# Callbacks
-
1
before_validation :generate_uuid, on: :create
-
1
before_create :capture_request_context
-
1
after_create :ensure_immutable
-
-
# Scopes
-
1
scope :by_event_type, ->(type) { where(event_type: type) }
-
1
scope :by_action, ->(action) { where(action: action) }
-
1
scope :by_actor, ->(actor_id) { where(actor_id: actor_id) }
-
1
scope :by_target, ->(target_type, target_id) { where(target_type: target_type, target_id: target_id) }
-
1
scope :by_organization, ->(org_id) { where(organization_id: org_id) }
-
1
scope :recent, -> { order(created_at: :desc) }
-
1
scope :since, ->(time) { where(:created_at.gte => time) }
-
1
scope :until, ->(time) { where(:created_at.lte => time) }
-
1
scope :tagged, ->(tag) { where(tags: tag) }
-
-
# Event types
-
1
TYPES = {
-
identity: "identity",
-
content: "content",
-
workflow: "workflow",
-
system: "system",
-
security: "security",
-
record: "record", # Records management / retention
-
hr: "hr" # Human resources / Intranet
-
}.freeze
-
-
# Common actions
-
1
ACTIONS = {
-
create: "create",
-
read: "read",
-
update: "update",
-
delete: "delete",
-
restore: "restore",
-
login: "login",
-
logout: "logout",
-
permission_change: "permission_change",
-
export: "export",
-
import: "import"
-
}.freeze
-
-
# Document-specific actions
-
1
DOCUMENT_ACTIONS = {
-
document_created: "document_created",
-
document_updated: "document_updated",
-
document_deleted: "document_deleted",
-
document_restored: "document_restored",
-
document_locked: "document_locked",
-
document_unlocked: "document_unlocked",
-
document_moved: "document_moved",
-
document_status_changed: "document_status_changed",
-
version_created: "version_created",
-
version_downloaded: "version_downloaded",
-
version_viewed: "version_viewed"
-
}.freeze
-
-
# Folder-specific actions
-
1
FOLDER_ACTIONS = {
-
folder_created: "folder_created",
-
folder_updated: "folder_updated",
-
folder_deleted: "folder_deleted",
-
folder_moved: "folder_moved"
-
}.freeze
-
-
# All valid actions combined
-
1
ALL_ACTIONS = ACTIONS.merge(DOCUMENT_ACTIONS).merge(FOLDER_ACTIONS).freeze
-
-
1
class << self
-
1
def log(event_type:, action:, target: nil, actor: nil, change_data: {}, metadata: {}, tags: [])
-
6
create!(
-
event_type: event_type,
-
action: action,
-
actor_id: actor&.id,
-
actor_type: actor&.class&.name,
-
actor_email: actor.try(:email),
-
actor_name: actor.try(:full_name) || actor.try(:name),
-
target_id: target&.id,
-
target_type: target&.class&.name,
-
target_uuid: target.try(:uuid),
-
organization_id: extract_organization_id(actor, target),
-
change_data: change_data,
-
metadata: metadata,
-
tags: Array(tags)
-
)
-
end
-
-
1
def log_model_change(record, action, change_data = {})
-
31
actor = Current.user
-
-
31
create!(
-
event_type: event_type_for_model(record),
-
action: action,
-
actor_id: actor&.id,
-
actor_type: actor&.class&.name,
-
actor_email: actor.try(:email),
-
actor_name: actor.try(:full_name) || actor.try(:name),
-
target_id: record.id,
-
target_type: record.class.name,
-
target_uuid: record.try(:uuid),
-
organization_id: extract_organization_id(actor, record),
-
change_data: change_data,
-
495
previous_values: change_data.transform_values { |v| v.is_a?(Array) ? v.first : nil },
-
495
new_values: change_data.transform_values { |v| v.is_a?(Array) ? v.last : v }
-
)
-
end
-
-
# Specialized document audit logging
-
1
def log_document_action(action:, document:, actor: nil, change_data: {}, metadata: {})
-
actor ||= Current.user
-
log(
-
event_type: TYPES[:content],
-
action: action,
-
target: document,
-
actor: actor,
-
change_data: change_data,
-
metadata: metadata.merge(
-
document_title: document.try(:title),
-
document_status: document.try(:status)
-
),
-
tags: ["document", action]
-
)
-
end
-
-
# Specialized version audit logging
-
1
def log_version_action(action:, version:, actor: nil, metadata: {})
-
actor ||= Current.user
-
log(
-
event_type: TYPES[:content],
-
action: action,
-
target: version,
-
actor: actor,
-
metadata: metadata.merge(
-
document_id: version.document_id&.to_s,
-
version_number: version.version_number,
-
file_name: version.file_name,
-
content_type: version.content_type,
-
file_size: version.file_size
-
),
-
tags: ["version", action]
-
)
-
end
-
-
# Specialized folder audit logging
-
1
def log_folder_action(action:, folder:, actor: nil, change_data: {}, metadata: {})
-
actor ||= Current.user
-
log(
-
event_type: TYPES[:content],
-
action: action,
-
target: folder,
-
actor: actor,
-
change_data: change_data,
-
metadata: metadata.merge(
-
folder_name: folder.try(:name),
-
folder_path: folder.try(:path)
-
),
-
tags: ["folder", action]
-
)
-
end
-
-
# Query methods for audit trail
-
1
def for_document(document)
-
where(target_type: "Content::Document", target_id: document.id)
-
.or(where("metadata.document_id" => document.id.to_s))
-
.recent
-
end
-
-
1
def for_version(version)
-
where(target_type: "Content::DocumentVersion", target_id: version.id).recent
-
end
-
-
1
def for_folder(folder)
-
where(target_type: "Content::Folder", target_id: folder.id).recent
-
end
-
-
1
def for_user(user)
-
where(actor_id: user.id).recent
-
end
-
-
1
private
-
-
1
def extract_organization_id(actor, target)
-
37
actor.try(:organization_id) || target.try(:organization_id) || Current.organization&.id
-
end
-
-
1
def event_type_for_model(record)
-
31
class_name = record.class.name
-
31
case class_name
-
when /\AIdentity::/
-
31
TYPES[:identity]
-
when /\AContent::/
-
TYPES[:content]
-
when /\AWorkflow::/
-
TYPES[:workflow]
-
when /\AHr::/
-
TYPES[:hr]
-
when /\ARetention::/
-
TYPES[:record]
-
else
-
# Audit::* and all other models default to system
-
TYPES[:system]
-
end
-
end
-
end
-
-
# ============================================
-
# IMMUTABILITY ENFORCEMENT (Append-Only Log)
-
# ============================================
-
-
# Instance-level immutability
-
-
1
def save(*)
-
return super if new_record?
-
-
raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
-
end
-
-
1
def save!(*)
-
return super if new_record?
-
-
raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
-
end
-
-
1
def update(*)
-
raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
-
end
-
-
1
def update!(*)
-
raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
-
end
-
-
1
def delete
-
raise ImmutableRecordError, "AuditEvent records cannot be deleted"
-
end
-
-
1
def destroy
-
raise ImmutableRecordError, "AuditEvent records cannot be deleted"
-
end
-
-
1
def destroy!
-
raise ImmutableRecordError, "AuditEvent records cannot be deleted"
-
end
-
-
1
def remove
-
raise ImmutableRecordError, "AuditEvent records cannot be deleted"
-
end
-
-
# Class-level protection against mass operations
-
# These are defined on the class itself, not on Mongoid::Criteria
-
1
def self.delete_all(*)
-
raise ImmutableRecordError, "AuditEvent records cannot be deleted in bulk"
-
end
-
-
1
def self.destroy_all(*)
-
raise ImmutableRecordError, "AuditEvent records cannot be deleted in bulk"
-
end
-
-
1
def self.update_all(*)
-
raise ImmutableRecordError, "AuditEvent records cannot be updated in bulk"
-
end
-
-
1
private
-
-
1
def generate_uuid
-
37
self.uuid ||= SecureRandom.uuid
-
end
-
-
1
def capture_request_context
-
37
self.request_id ||= Current.request_id
-
37
self.ip_address ||= Current.ip_address
-
37
self.user_agent ||= Current.user_agent
-
end
-
-
1
def ensure_immutable
-
37
readonly!
-
end
-
-
1
class ImmutableRecordError < StandardError; end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
1
module AuditTrackable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
2
after_create :audit_create
-
2
after_update :audit_update
-
2
after_destroy :audit_destroy
-
-
2
class_attribute :audit_skip_fields, default: ["updated_at", "created_at", "search_text"]
-
2
class_attribute :audit_enabled, default: true
-
end
-
-
1
module ClassMethods
-
1
def skip_audit_for(*fields)
-
self.audit_skip_fields = audit_skip_fields + fields.map(&:to_s)
-
end
-
-
1
def disable_audit
-
self.audit_enabled = false
-
end
-
-
1
def without_audit
-
original_value = audit_enabled
-
self.audit_enabled = false
-
yield
-
ensure
-
self.audit_enabled = original_value
-
end
-
end
-
-
1
private
-
-
1
def audit_create
-
31
return unless audit_enabled?
-
-
31
Audit::AuditEvent.log_model_change(self, "create", auditable_attributes)
-
end
-
-
1
def audit_update
-
return unless audit_enabled?
-
return if auditable_changes.empty?
-
-
Audit::AuditEvent.log_model_change(self, "update", auditable_changes)
-
end
-
-
1
def audit_destroy
-
return unless audit_enabled?
-
-
Audit::AuditEvent.log_model_change(self, "delete", auditable_attributes)
-
end
-
-
1
def audit_enabled?
-
self.class.audit_enabled
-
end
-
-
1
def auditable_changes
-
changes.except(*self.class.audit_skip_fields)
-
end
-
-
1
def auditable_attributes
-
31
attributes.except(*self.class.audit_skip_fields)
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module SoftDeletable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
2
field :deleted_at, type: Time
-
2
field :deleted_by_id, type: BSON::ObjectId
-
-
2
index({ deleted_at: 1 })
-
-
60
scope :active, -> { where(deleted_at: nil) }
-
2
scope :deleted, -> { where(:deleted_at.ne => nil) }
-
2
scope :with_deleted, -> { unscoped }
-
-
101
default_scope -> { active }
-
end
-
-
1
def soft_delete(user = nil)
-
return false if deleted?
-
-
update(
-
deleted_at: Time.current,
-
deleted_by_id: (user || Current.user)&.id
-
)
-
end
-
-
1
def soft_delete!(user = nil)
-
soft_delete(user) || raise(Mongoid::Errors::DocumentNotFound.new(self.class, id))
-
end
-
-
1
def restore
-
return false unless deleted?
-
-
update(
-
deleted_at: nil,
-
deleted_by_id: nil
-
)
-
end
-
-
1
def restore!
-
restore || raise(Mongoid::Errors::DocumentNotFound.new(self.class, id))
-
end
-
-
1
def deleted?
-
deleted_at.present?
-
end
-
-
1
def deleted_by
-
return nil unless deleted_by_id
-
-
@deleted_by ||= Identity::User.find(deleted_by_id)
-
rescue Mongoid::Errors::DocumentNotFound
-
nil
-
end
-
-
1
module ClassMethods
-
1
def soft_delete_all(user = nil)
-
update_all(
-
deleted_at: Time.current,
-
deleted_by_id: (user || Current.user)&.id
-
)
-
end
-
-
1
def restore_all
-
unscoped.where(:deleted_at.ne => nil).update_all(
-
deleted_at: nil,
-
deleted_by_id: nil
-
)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module UuidIdentifiable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
4
field :uuid, type: String
-
-
4
index({ uuid: 1 }, { unique: true })
-
-
4
before_create :generate_uuid
-
-
4
validates :uuid, uniqueness: true, allow_nil: true
-
end
-
-
1
private
-
-
1
def generate_uuid
-
51
self.uuid ||= SecureRandom.uuid
-
end
-
-
1
module ClassMethods
-
1
def find_by_uuid(uuid)
-
where(uuid: uuid).first
-
end
-
-
1
def find_by_uuid!(uuid)
-
find_by_uuid(uuid) || raise(Mongoid::Errors::DocumentNotFound.new(self, { uuid: uuid }))
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Content
-
# rubocop:disable Metrics/ClassLength
-
class Document
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
include SoftDeletable
-
include AuditTrackable
-
-
store_in collection: "content_documents"
-
-
# Status constants
-
STATUS_DRAFT = "draft"
-
STATUS_PENDING_REVIEW = "pending_review"
-
STATUS_PUBLISHED = "published"
-
STATUS_ARCHIVED = "archived"
-
-
STATUSES = [STATUS_DRAFT, STATUS_PENDING_REVIEW, STATUS_PUBLISHED, STATUS_ARCHIVED].freeze
-
-
# Fields
-
field :title, type: String
-
field :description, type: String
-
field :status, type: String, default: STATUS_DRAFT
-
field :document_type, type: String
-
field :tags, type: Array, default: []
-
field :metadata, type: Hash, default: {}
-
-
# Versioning fields
-
field :current_version_number, type: Integer, default: 0
-
field :version_count, type: Integer, default: 0
-
-
# Locking for concurrency control
-
field :lock_version, type: Integer, default: 0
-
field :locked_by_id, type: BSON::ObjectId
-
field :locked_at, type: Time
-
-
# Retention status
-
field :retention_status, type: String # nil, "archived", "expired"
-
field :last_modified_by_id, type: BSON::ObjectId
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ title: 1 })
-
index({ status: 1 })
-
index({ document_type: 1 })
-
index({ tags: 1 })
-
index({ folder_id: 1 })
-
index({ organization_id: 1 })
-
index({ created_by_id: 1 })
-
index({ current_version_id: 1 })
-
index({ locked_by_id: 1 })
-
index({ created_at: -1 })
-
# Compound indexes for search optimization
-
index({ organization_id: 1, status: 1, created_at: -1 })
-
index({ organization_id: 1, folder_id: 1, status: 1 })
-
index({ organization_id: 1, tags: 1 })
-
index({ retention_status: 1 })
-
-
# Associations
-
belongs_to :folder, class_name: "Content::Folder", optional: true, inverse_of: :documents
-
belongs_to :organization, class_name: "Identity::Organization", optional: true
-
belongs_to :created_by, class_name: "Identity::User", optional: true
-
belongs_to :last_modified_by, class_name: "Identity::User", optional: true
-
has_one :retention_schedule, class_name: "Retention::RetentionSchedule", inverse_of: :document
-
belongs_to :current_version, class_name: "Content::DocumentVersion", optional: true
-
has_many :versions, class_name: "Content::DocumentVersion", inverse_of: :document, order: :version_number.desc
-
-
# Validations
-
validates :title, presence: true, length: { minimum: 1, maximum: 255 }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validate :folder_belongs_to_same_organization
-
validate :legal_hold_prevents_modification, on: :update
-
-
# Audit callbacks - EVERY action must be audited
-
after_create :audit_document_created
-
after_update :audit_document_updated
-
before_destroy :prevent_hard_delete
-
-
# Scopes
-
scope :drafts, -> { where(status: STATUS_DRAFT) }
-
scope :pending_review, -> { where(status: STATUS_PENDING_REVIEW) }
-
scope :published, -> { where(status: STATUS_PUBLISHED) }
-
scope :archived, -> { where(status: STATUS_ARCHIVED) }
-
scope :not_archived, -> { where(:status.ne => STATUS_ARCHIVED) }
-
scope :by_status, ->(status) { where(status: status) }
-
scope :by_type, ->(type) { where(document_type: type) }
-
scope :by_folder, ->(folder_id) { where(folder_id: folder_id) }
-
scope :by_organization, ->(org_id) { where(organization_id: org_id) }
-
scope :tagged_with, ->(tag) { where(tags: tag) }
-
scope :locked, -> { where(:locked_by_id.ne => nil) }
-
scope :unlocked, -> { where(locked_by_id: nil) }
-
scope :recent, ->(limit = 10) { order(created_at: :desc).limit(limit) }
-
scope :retention_archived, -> { where(retention_status: "archived") }
-
scope :retention_expired, -> { where(retention_status: "expired") }
-
-
# Check if document is under legal hold
-
def under_legal_hold?
-
retention_schedule&.under_legal_hold? || false
-
end
-
-
# Check if modification is allowed (respects legal hold)
-
def modification_allowed?
-
!under_legal_hold?
-
end
-
-
# Create new version
-
def create_version!(attributes = {})
-
check_lock!
-
check_legal_hold!
-
-
version_attrs = attributes.merge(
-
document: self,
-
created_by: attributes[:created_by] || Current.user
-
)
-
-
version = Content::DocumentVersion.create!(version_attrs)
-
-
# Update document with new current version - use ID for MongoDB serialization
-
update_with_lock!(
-
current_version_id: version.id,
-
current_version_number: version.version_number,
-
version_count: versions.count
-
)
-
-
version
-
end
-
-
# Optimistic locking update
-
def update_with_lock!(attrs)
-
current_lock = lock_version
-
-
result = self.class.where(
-
_id: id,
-
lock_version: current_lock
-
).find_one_and_update(
-
{ "$set" => attrs.merge(lock_version: current_lock + 1, updated_at: Time.current) },
-
return_document: :after
-
)
-
-
if result.nil?
-
reload
-
raise ConcurrencyError, "Document was modified by another process. Please reload and try again."
-
end
-
-
# Reload to get updated attributes
-
reload
-
self
-
end
-
-
# Locking methods
-
def lock!(user)
-
return false if locked? && locked_by_id != user.id
-
-
update_with_lock!(
-
locked_by_id: user.id,
-
locked_at: Time.current
-
)
-
audit_lock_event("document_locked", user)
-
true
-
rescue ConcurrencyError
-
false
-
end
-
-
def unlock!(user = nil)
-
return false unless locked?
-
return false if user && locked_by_id != user.id && !user.admin?
-
-
update_with_lock!(
-
locked_by_id: nil,
-
locked_at: nil
-
)
-
audit_lock_event("document_unlocked", user)
-
true
-
rescue ConcurrencyError
-
false
-
end
-
-
def locked?
-
locked_by_id.present?
-
end
-
-
def locked_by?(user)
-
locked_by_id == user.id
-
end
-
-
def locked_by
-
return nil unless locked_by_id
-
-
@locked_by ||= Identity::User.find(locked_by_id)
-
rescue Mongoid::Errors::DocumentNotFound
-
nil
-
end
-
-
# Version access
-
def latest_version
-
current_version || versions.first
-
end
-
-
def version(number)
-
versions.find_by(version_number: number)
-
end
-
-
def version_history
-
versions.order(version_number: :asc)
-
end
-
-
# Status transitions
-
def publish!
-
update!(status: STATUS_PUBLISHED)
-
end
-
-
def archive!
-
update!(status: STATUS_ARCHIVED)
-
end
-
-
def submit_for_review!
-
update!(status: STATUS_PENDING_REVIEW)
-
end
-
-
# rubocop:disable Metrics/PerceivedComplexity
-
def move_to_folder!(new_folder)
-
return false if new_folder && new_folder.organization_id != organization_id
-
-
old_folder_id = folder_id
-
result = update!(folder: new_folder)
-
-
if result
-
Audit::AuditEvent.log_document_action(
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_moved],
-
document: self,
-
change_data: { folder_id: [old_folder_id&.to_s, new_folder&.id&.to_s] },
-
metadata: {
-
old_folder_path: old_folder_id ? Content::Folder.find(old_folder_id)&.path : nil,
-
new_folder_path: new_folder&.path
-
}
-
)
-
end
-
-
result
-
end
-
# rubocop:enable Metrics/PerceivedComplexity
-
-
# Soft delete with audit trail
-
def soft_delete_with_audit!(user = nil)
-
user ||= Current.user
-
result = soft_delete(user)
-
-
if result
-
Audit::AuditEvent.log_document_action(
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_deleted],
-
document: self,
-
actor: user,
-
change_data: { deleted_at: [nil, deleted_at&.iso8601] },
-
metadata: { soft_delete: true }
-
)
-
end
-
-
result
-
end
-
-
# Restore with audit trail
-
def restore_with_audit!(user = nil)
-
user ||= Current.user
-
old_deleted_at = deleted_at
-
result = restore
-
-
if result
-
Audit::AuditEvent.log_document_action(
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_restored],
-
document: self,
-
actor: user,
-
change_data: { deleted_at: [old_deleted_at&.iso8601, nil] },
-
metadata: { restored: true }
-
)
-
end
-
-
result
-
end
-
-
# Get complete audit trail for this document
-
def audit_trail
-
Audit::AuditEvent.for_document(self)
-
end
-
-
private
-
-
def check_lock!
-
return unless locked?
-
return if locked_by_id == Current.user&.id
-
-
raise DocumentLockedError, "Document is locked by another user"
-
end
-
-
def check_legal_hold!
-
return unless under_legal_hold?
-
-
raise LegalHoldError, "Document is under legal hold and cannot be modified"
-
end
-
-
def legal_hold_prevents_modification
-
return unless under_legal_hold?
-
# Only allow retention_status changes when under hold
-
return if [["retention_status"], ["retention_status", "updated_at"]].include?(changes.keys)
-
return if changes.keys == ["updated_at"]
-
return if changes.empty?
-
-
errors.add(:base, "Document is under legal hold and cannot be modified")
-
end
-
-
def folder_belongs_to_same_organization
-
return unless folder && organization_id
-
-
return if folder.organization_id == organization_id
-
-
errors.add(:folder, "must belong to the same organization")
-
end
-
-
def prevent_hard_delete
-
raise HardDeleteNotAllowedError, "Documents cannot be hard deleted. Use soft_delete_with_audit! instead."
-
end
-
-
def audit_document_created
-
Audit::AuditEvent.log_document_action(
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_created],
-
document: self,
-
change_data: attributes.except("_id", "updated_at", "created_at"),
-
metadata: { initial_status: status }
-
)
-
end
-
-
def audit_document_updated
-
# Use previous_changes since changes is cleared after save
-
relevant_changes = previous_changes.except("updated_at", "lock_version", "created_at")
-
return if relevant_changes.empty?
-
-
# Detect status change for special logging
-
action = if relevant_changes.key?("status")
-
Audit::AuditEvent::DOCUMENT_ACTIONS[:document_status_changed]
-
else
-
Audit::AuditEvent::DOCUMENT_ACTIONS[:document_updated]
-
end
-
-
Audit::AuditEvent.log_document_action(
-
action: action,
-
document: self,
-
change_data: relevant_changes,
-
metadata: build_update_metadata(relevant_changes)
-
)
-
end
-
-
def audit_lock_event(action, user)
-
Audit::AuditEvent.log_document_action(
-
action: action,
-
document: self,
-
actor: user,
-
change_data: { locked_by_id: locked_by_id&.to_s, locked_at: locked_at&.iso8601 },
-
metadata: { lock_action: action }
-
)
-
end
-
-
def build_update_metadata(changes)
-
metadata = {}
-
metadata[:status_transition] = changes["status"] if changes.key?("status")
-
metadata[:title_changed] = true if changes.key?("title")
-
metadata[:folder_changed] = true if changes.key?("folder_id")
-
metadata[:version_updated] = true if changes.key?("current_version_id")
-
metadata
-
end
-
-
class ConcurrencyError < StandardError; end
-
class DocumentLockedError < StandardError; end
-
class HardDeleteNotAllowedError < StandardError; end
-
class LegalHoldError < StandardError; end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
module Content
-
class DocumentVersion
-
include Mongoid::Document
-
include Mongoid::Timestamps::Created
-
include UuidIdentifiable
-
-
store_in collection: "document_versions"
-
-
# Fields - all immutable after creation
-
field :version_number, type: Integer
-
field :file_name, type: String
-
field :file_size, type: Integer
-
field :content_type, type: String
-
field :checksum, type: String
-
field :storage_key, type: String
-
field :content, type: String # For text content (can be replaced with file storage)
-
-
# Version metadata
-
field :change_summary, type: String
-
field :metadata, type: Hash, default: {}
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ document_id: 1, version_number: 1 }, { unique: true })
-
index({ document_id: 1, created_at: -1 })
-
index({ checksum: 1 })
-
index({ created_by_id: 1 })
-
-
# Associations
-
belongs_to :document, class_name: "Content::Document", inverse_of: :versions
-
belongs_to :created_by, class_name: "Identity::User", optional: true
-
-
# Validations
-
validates :version_number, presence: true, numericality: { greater_than: 0 }
-
validates :version_number, uniqueness: { scope: :document_id }
-
validates :file_name, presence: true, length: { maximum: 255 }
-
validates :content_type, presence: true
-
validates :checksum, presence: true
-
-
# Callbacks
-
before_validation :set_version_number, on: :create
-
before_validation :calculate_checksum, on: :create
-
after_create :log_version_created
-
-
# Immutability enforcement
-
def save(*)
-
return super if new_record?
-
-
raise ImmutableRecordError, "DocumentVersion records cannot be modified after creation"
-
end
-
-
def update(*)
-
raise ImmutableRecordError, "DocumentVersion records cannot be modified after creation"
-
end
-
-
def update!(*)
-
raise ImmutableRecordError, "DocumentVersion records cannot be modified after creation"
-
end
-
-
def delete
-
raise ImmutableRecordError, "DocumentVersion records cannot be deleted"
-
end
-
-
def destroy
-
raise ImmutableRecordError, "DocumentVersion records cannot be deleted"
-
end
-
-
# Instance methods
-
def previous_version
-
return nil if version_number == 1
-
-
document.versions.where(version_number: version_number - 1).first
-
end
-
-
def next_version
-
document.versions.where(version_number: version_number + 1).first
-
end
-
-
def latest?
-
document.current_version_id == id
-
end
-
-
def content_changed_from_previous?
-
return true if version_number == 1
-
-
prev = previous_version
-
prev.nil? || prev.checksum != checksum
-
end
-
-
# Audit methods for tracking access (these don't modify the version, just log)
-
-
# Log a download event for this version
-
def log_download!(user = nil)
-
user ||= Current.user
-
Audit::AuditEvent.log_version_action(
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_downloaded],
-
version: self,
-
actor: user,
-
metadata: {
-
download_timestamp: Time.current.iso8601,
-
user_ip: Current.ip_address,
-
user_agent: Current.user_agent
-
}
-
)
-
end
-
-
# Log a view event for this version
-
def log_view!(user = nil)
-
user ||= Current.user
-
Audit::AuditEvent.log_version_action(
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_viewed],
-
version: self,
-
actor: user,
-
metadata: {
-
view_timestamp: Time.current.iso8601
-
}
-
)
-
end
-
-
# Get download count from audit trail
-
def download_count
-
Audit::AuditEvent.where(
-
target_type: "Content::DocumentVersion",
-
target_id: id,
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_downloaded]
-
).count
-
end
-
-
# Get view count from audit trail
-
def view_count
-
Audit::AuditEvent.where(
-
target_type: "Content::DocumentVersion",
-
target_id: id,
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_viewed]
-
).count
-
end
-
-
# Get complete audit trail for this version
-
def audit_trail
-
Audit::AuditEvent.for_version(self)
-
end
-
-
private
-
-
def set_version_number
-
return if version_number.present?
-
-
max_version = document&.versions&.max(:version_number) || 0
-
self.version_number = max_version + 1
-
end
-
-
def calculate_checksum
-
return if checksum.present?
-
return if content.blank? && storage_key.blank?
-
-
content_to_hash = content || storage_key
-
self.checksum = Digest::SHA256.hexdigest(content_to_hash)
-
end
-
-
def log_version_created
-
Audit::AuditEvent.log_version_action(
-
action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_created],
-
version: self,
-
actor: created_by || Current.user,
-
metadata: {
-
change_summary: change_summary,
-
content_changed: content_changed_from_previous?
-
}
-
)
-
end
-
-
class ImmutableRecordError < StandardError; end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Content
-
# rubocop:disable Metrics/ClassLength
-
class Folder
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
include SoftDeletable
-
include AuditTrackable
-
-
store_in collection: "folders"
-
-
# Fields
-
field :name, type: String
-
field :description, type: String
-
field :path, type: String
-
field :depth, type: Integer, default: 0
-
field :metadata, type: Hash, default: {}
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ name: 1 })
-
index({ path: 1 }, { unique: true })
-
index({ parent_id: 1 })
-
index({ organization_id: 1 })
-
index({ depth: 1 })
-
index({ created_by_id: 1 })
-
-
# Associations
-
belongs_to :parent, class_name: "Content::Folder", optional: true, inverse_of: :children
-
has_many :children, class_name: "Content::Folder", inverse_of: :parent, dependent: :restrict_with_error
-
has_many :documents, class_name: "Content::Document", inverse_of: :folder, dependent: :restrict_with_error
-
belongs_to :organization, class_name: "Identity::Organization", optional: true
-
belongs_to :created_by, class_name: "Identity::User", optional: true
-
-
# Validations
-
validates :name, presence: true, length: { minimum: 1, maximum: 255 }
-
validates :name, format: { with: %r{\A[^/\\]+\z}, message: "cannot contain slashes" }
-
validates :path, presence: true, uniqueness: { scope: :organization_id }
-
validate :parent_not_self
-
validate :parent_depth_limit
-
-
# Callbacks
-
before_validation :build_path, on: :create
-
before_validation :update_path, on: :update, if: :parent_id_changed?
-
# Audit callbacks
-
after_create :audit_folder_created
-
after_update :audit_folder_updated
-
before_destroy :prevent_hard_delete
-
after_save :update_children_paths, if: :saved_change_to_path?
-
-
# Constants
-
MAX_DEPTH = 10
-
-
# Scopes
-
scope :root_folders, -> { where(parent_id: nil) }
-
scope :by_organization, ->(org_id) { where(organization_id: org_id) }
-
scope :by_parent, ->(parent_id) { where(parent_id: parent_id) }
-
scope :alphabetical, -> { order(name: :asc) }
-
-
def root?
-
parent_id.nil?
-
end
-
-
def ancestors
-
return [] if root?
-
-
ancestors_list = []
-
current = parent
-
while current
-
ancestors_list.unshift(current)
-
current = current.parent
-
end
-
ancestors_list
-
end
-
-
def ancestor_ids
-
ancestors.map(&:id)
-
end
-
-
def descendants
-
all_descendants = []
-
children.each do |child|
-
all_descendants << child
-
all_descendants.concat(child.descendants)
-
end
-
all_descendants
-
end
-
-
def descendant_ids
-
descendants.map(&:id)
-
end
-
-
def full_path
-
path
-
end
-
-
# rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity, Naming/PredicateMethod
-
def move_to(new_parent)
-
return false if new_parent == self
-
return false if new_parent && descendant_ids.include?(new_parent.id)
-
-
old_parent_id = parent_id
-
old_path = path
-
self.parent = new_parent
-
-
if save
-
Audit::AuditEvent.log_folder_action(
-
action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_moved],
-
folder: self,
-
change_data: {
-
parent_id: [old_parent_id&.to_s, new_parent&.id&.to_s],
-
path: [old_path, path]
-
},
-
metadata: {
-
old_parent_path: old_parent_id ? Content::Folder.find(old_parent_id)&.path : nil,
-
new_parent_path: new_parent&.path
-
}
-
)
-
true
-
else
-
false
-
end
-
end
-
# rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity, Naming/PredicateMethod
-
-
def document_count(include_descendants: false)
-
count = documents.count
-
if include_descendants
-
children.each do |child|
-
count += child.document_count(include_descendants: true)
-
end
-
end
-
count
-
end
-
-
# Soft delete with audit trail
-
def soft_delete_with_audit!(user = nil)
-
user ||= Current.user
-
result = soft_delete(user)
-
-
if result
-
Audit::AuditEvent.log_folder_action(
-
action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_deleted],
-
folder: self,
-
actor: user,
-
change_data: { deleted_at: [nil, deleted_at&.iso8601] },
-
metadata: { soft_delete: true }
-
)
-
end
-
-
result
-
end
-
-
# Get complete audit trail for this folder
-
def audit_trail
-
Audit::AuditEvent.for_folder(self)
-
end
-
-
private
-
-
def build_path
-
self.path = if parent
-
"#{parent.path}/#{name}"
-
else
-
"/#{name}"
-
end
-
self.depth = parent ? parent.depth + 1 : 0
-
end
-
-
def update_path
-
build_path
-
end
-
-
def update_children_paths
-
children.each do |child|
-
child.send(:build_path)
-
child.save!
-
end
-
end
-
-
def parent_not_self
-
return unless parent_id.present? && parent_id == id
-
-
errors.add(:parent_id, "cannot be self")
-
end
-
-
def parent_depth_limit
-
# MAX_DEPTH is the maximum allowed depth value (0-based)
-
# A folder at depth MAX_DEPTH-1 cannot have children
-
return unless parent && parent.depth >= MAX_DEPTH - 1
-
-
errors.add(:parent_id, "maximum folder depth (#{MAX_DEPTH}) exceeded")
-
end
-
-
def prevent_hard_delete
-
raise HardDeleteNotAllowedError, "Folders cannot be hard deleted. Use soft_delete_with_audit! instead."
-
end
-
-
def audit_folder_created
-
Audit::AuditEvent.log_folder_action(
-
action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_created],
-
folder: self,
-
change_data: attributes.except("_id", "updated_at", "created_at"),
-
metadata: { initial_depth: depth }
-
)
-
end
-
-
def audit_folder_updated
-
relevant_changes = previous_changes.except("updated_at", "created_at")
-
return if relevant_changes.empty?
-
-
Audit::AuditEvent.log_folder_action(
-
action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_updated],
-
folder: self,
-
change_data: relevant_changes,
-
metadata: {
-
name_changed: relevant_changes.key?("name"),
-
path_changed: relevant_changes.key?("path")
-
}
-
)
-
end
-
-
class HardDeleteNotAllowedError < StandardError; end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
1
class Current < ActiveSupport::CurrentAttributes
-
1
attribute :user
-
1
attribute :organization
-
1
attribute :request_id
-
1
attribute :ip_address
-
1
attribute :user_agent
-
-
1
resets do
-
23
Time.zone = "UTC"
-
end
-
-
1
def user=(value)
-
super
-
Time.zone = value&.time_zone || "UTC"
-
end
-
end
-
# frozen_string_literal: true
-
-
module Documents
-
class Folder
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
# Fields
-
field :name, type: String
-
field :description, type: String
-
field :color, type: String, default: "#6366f1" # Primary color
-
field :icon, type: String, default: "folder"
-
field :is_system, type: Boolean, default: false
-
field :documents_count, type: Integer, default: 0
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :created_by, class_name: "Identity::User"
-
belongs_to :parent, class_name: "Documents::Folder", optional: true
-
has_many :subfolders, class_name: "Documents::Folder", inverse_of: :parent, dependent: :destroy
-
has_many :folder_documents, class_name: "Documents::FolderDocument", dependent: :destroy
-
-
# Validations
-
validates :name, presence: true, length: { maximum: 100 }
-
validates :name, uniqueness: { scope: [:organization_id, :parent_id] }
-
validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/, message: "debe ser un color hexadecimal válido" }, allow_blank: true
-
-
# Indexes
-
index({ organization_id: 1, parent_id: 1, name: 1 }, { unique: true })
-
index({ organization_id: 1, created_at: -1 })
-
-
# Scopes
-
scope :for_organization, ->(org) { where(organization_id: org.id) }
-
scope :root_folders, -> { where(parent_id: nil) }
-
scope :ordered, -> { order(name: :asc) }
-
-
# Callbacks
-
before_destroy :prevent_system_folder_deletion
-
-
# Get full path of folder
-
def full_path
-
ancestors = []
-
current = self
-
while current
-
ancestors.unshift(current.name)
-
current = current.parent
-
end
-
ancestors.join(" / ")
-
end
-
-
# Get all ancestor folders
-
def ancestors
-
result = []
-
current = parent
-
while current
-
result.unshift(current)
-
current = current.parent
-
end
-
result
-
end
-
-
# Get documents in this folder
-
def documents
-
folder_documents.includes(:document).map(&:document).compact
-
end
-
-
# Update documents count
-
def update_documents_count!
-
update!(documents_count: folder_documents.count)
-
end
-
-
private
-
-
def prevent_system_folder_deletion
-
if is_system
-
errors.add(:base, "No se puede eliminar una carpeta del sistema")
-
throw(:abort)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Documents
-
class FolderDocument
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
-
# Associations
-
belongs_to :folder, class_name: "Documents::Folder"
-
belongs_to :document, class_name: "Templates::GeneratedDocument"
-
belongs_to :added_by, class_name: "Identity::User"
-
-
# Validations
-
validates :document_id, uniqueness: { scope: :folder_id, message: "ya está en esta carpeta" }
-
-
# Indexes
-
index({ folder_id: 1, document_id: 1 }, { unique: true })
-
index({ document_id: 1 })
-
-
# Callbacks
-
after_create :increment_folder_count
-
after_destroy :decrement_folder_count
-
-
private
-
-
def increment_folder_count
-
folder.inc(documents_count: 1)
-
end
-
-
def decrement_folder_count
-
folder.inc(documents_count: -1)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class HealthCheck
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
-
store_in collection: "health_checks"
-
-
field :status, type: String, default: "ok"
-
field :checked_at, type: Time
-
-
validates :status, presence: true, inclusion: { in: ["ok", "degraded", "error"] }
-
-
before_create :set_checked_at
-
-
def self.ping
-
create!(status: "ok")
-
true
-
rescue StandardError
-
false
-
end
-
-
def self.mongodb_connected?
-
Mongoid.default_client.command(ping: 1).ok?
-
rescue StandardError
-
false
-
end
-
-
private
-
-
def set_checked_at
-
self.checked_at = Time.current
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Hr
-
# Employee profile extending User with HR-specific data
-
# Tracks vacation balance, supervisor hierarchy, and employment details
-
#
-
1
class Employee
-
1
include Mongoid::Document
-
1
include Mongoid::Timestamps
-
1
include UuidIdentifiable
-
-
1
store_in collection: "hr_employees"
-
-
# Employment status constants
-
1
STATUS_ACTIVE = "active"
-
1
STATUS_ON_LEAVE = "on_leave"
-
1
STATUS_TERMINATED = "terminated"
-
1
STATUS_SUSPENDED = "suspended"
-
-
1
STATUSES = [STATUS_ACTIVE, STATUS_ON_LEAVE, STATUS_TERMINATED, STATUS_SUSPENDED].freeze
-
-
# Employment type constants
-
1
TYPE_FULL_TIME = "full_time"
-
1
TYPE_PART_TIME = "part_time"
-
1
TYPE_CONTRACTOR = "contractor"
-
1
TYPE_INTERN = "intern"
-
-
1
EMPLOYMENT_TYPES = [TYPE_FULL_TIME, TYPE_PART_TIME, TYPE_CONTRACTOR, TYPE_INTERN].freeze
-
-
# Contract type constants
-
1
CONTRACT_INDEFINITE = "indefinite"
-
1
CONTRACT_FIXED_TERM = "fixed_term"
-
1
CONTRACT_WORK_OR_LABOR = "work_or_labor"
-
1
CONTRACT_APPRENTICE = "apprentice"
-
-
1
CONTRACT_TYPES = [CONTRACT_INDEFINITE, CONTRACT_FIXED_TERM, CONTRACT_WORK_OR_LABOR, CONTRACT_APPRENTICE].freeze
-
-
# Fields
-
1
field :employee_number, type: String
-
1
field :employment_status, type: String, default: STATUS_ACTIVE
-
1
field :employment_type, type: String, default: TYPE_FULL_TIME
-
1
field :hire_date, type: Date
-
1
field :termination_date, type: Date
-
1
field :job_title, type: String
-
1
field :department, type: String
-
1
field :cost_center, type: String
-
-
# Personal name fields (stored independently, also used before user account exists)
-
1
field :first_name, type: String
-
1
field :last_name, type: String
-
-
# Contract fields
-
1
field :contract_type, type: String, default: CONTRACT_INDEFINITE
-
1
field :contract_template_id, type: String # UUID of the contract template
-
1
field :contract_start_date, type: Date
-
1
field :contract_end_date, type: Date # For fixed-term contracts
-
1
field :contract_duration_value, type: Integer # Duration value for fixed-term
-
1
field :contract_duration_unit, type: String, default: "months" # days, weeks, months, years
-
1
field :trial_period_days, type: Integer, default: 60
-
-
# Duration unit constants
-
1
DURATION_DAYS = "days"
-
1
DURATION_WEEKS = "weeks"
-
1
DURATION_MONTHS = "months"
-
1
DURATION_YEARS = "years"
-
-
1
DURATION_UNITS = [DURATION_DAYS, DURATION_WEEKS, DURATION_MONTHS, DURATION_YEARS].freeze
-
-
# Compensation fields
-
1
field :salary, type: BigDecimal # Monthly salary
-
1
field :food_allowance, type: BigDecimal, default: 0 # Auxilio de alimentacion
-
1
field :transport_allowance, type: BigDecimal, default: 0 # Auxilio de transporte
-
1
field :payment_frequency, type: String, default: "monthly" # weekly, biweekly, monthly
-
1
field :work_city, type: String # Ciudad donde labora
-
-
# Personal identification
-
1
field :identification_type, type: String, default: "CC" # CC, CE, PA, etc.
-
1
field :identification_number, type: String # Cedula
-
1
field :place_of_birth, type: String
-
1
field :nationality, type: String, default: "Colombiana"
-
1
field :address, type: String
-
1
field :phone, type: String
-
1
field :personal_email, type: String # Email personal para crear cuenta de acceso
-
1
field :work_email, type: String # Email corporativo (se actualiza cuando el usuario lo cambia)
-
-
# Vacation balance (mock for now - would integrate with payroll system)
-
1
field :vacation_balance_days, type: Float, default: 0.0
-
1
field :vacation_accrued_ytd, type: Float, default: 0.0
-
1
field :vacation_used_ytd, type: Float, default: 0.0
-
1
field :vacation_carry_over, type: Float, default: 0.0
-
-
# Sick leave balance
-
1
field :sick_leave_balance_days, type: Float, default: 0.0
-
1
field :sick_leave_used_ytd, type: Float, default: 0.0
-
-
# Personal data
-
1
field :date_of_birth, type: Date
-
1
field :emergency_contact_name, type: String
-
1
field :emergency_contact_phone, type: String
-
-
# Indexes
-
1
index({ uuid: 1 }, { unique: true })
-
1
index({ employee_number: 1 }, { unique: true, sparse: true })
-
1
index({ user_id: 1 }, { unique: true, sparse: true })
-
1
index({ supervisor_id: 1 })
-
1
index({ organization_id: 1 })
-
1
index({ employment_status: 1 })
-
1
index({ department: 1 })
-
1
index({ organization_id: 1, employment_status: 1 })
-
-
# Associations
-
1
belongs_to :user, class_name: "Identity::User", optional: true # Optional until account is created
-
1
belongs_to :supervisor, class_name: "Hr::Employee", optional: true
-
1
belongs_to :organization, class_name: "Identity::Organization"
-
1
has_many :subordinates, class_name: "Hr::Employee", inverse_of: :supervisor
-
1
has_many :vacation_requests, class_name: "Hr::VacationRequest", inverse_of: :employee
-
1
has_many :certification_requests, class_name: "Hr::EmploymentCertificationRequest", inverse_of: :employee
-
-
# Validations
-
1
validates :user_id, uniqueness: true, allow_blank: true
-
1
validates :employment_status, inclusion: { in: STATUSES }
-
1
validates :employment_type, inclusion: { in: EMPLOYMENT_TYPES }
-
1
validates :contract_type, inclusion: { in: CONTRACT_TYPES }, allow_blank: true
-
1
validates :vacation_balance_days, numericality: { greater_than_or_equal_to: 0 }
-
1
validates :employee_number, uniqueness: true, allow_blank: true
-
1
validates :salary, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
-
1
validates :personal_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
-
-
# Callbacks
-
1
after_save :sync_user_name, if: :should_sync_user_name?
-
-
# Scopes
-
1
scope :active, -> { where(employment_status: STATUS_ACTIVE) }
-
1
scope :on_leave, -> { where(employment_status: STATUS_ON_LEAVE) }
-
1
scope :terminated, -> { where(employment_status: STATUS_TERMINATED) }
-
1
scope :by_department, ->(dept) { where(department: dept) }
-
1
scope :by_supervisor, ->(supervisor) { where(supervisor_id: supervisor.id) }
-
1
scope :full_time, -> { where(employment_type: TYPE_FULL_TIME) }
-
-
# Delegate user attributes (when user exists)
-
1
delegate :email, to: :user, allow_nil: true
-
1
delegate :has_role?, :has_permission?, :admin?, to: :user
-
-
# Name methods - use local fields first, fallback to user
-
1
def display_first_name
-
10
first_name.presence || user&.first_name
-
end
-
-
1
def display_last_name
-
10
last_name.presence || user&.last_name
-
end
-
-
1
def full_name
-
10
"#{display_first_name} #{display_last_name}".strip
-
end
-
-
# Check if employee is a supervisor
-
1
def supervisor?
-
subordinates.active.exists?
-
end
-
-
# Check if employee is HR staff
-
1
def hr_staff?
-
2
return false unless user
-
2
user.has_role?("hr") || user.has_role?("hr_manager") || user.admin?
-
end
-
-
# Check if employee is HR manager
-
1
def hr_manager?
-
2
return false unless user
-
2
user.has_role?("hr_manager") || user.admin?
-
end
-
-
# Get associated contract template
-
1
def contract_template
-
return nil unless contract_template_id.present?
-
-
Templates::Template.find_by(uuid: contract_template_id)
-
end
-
-
# Check if this employee supervises another
-
1
def supervises?(other_employee)
-
2
return false unless other_employee
-
-
2
other_employee.supervisor_id == id
-
end
-
-
# Get all subordinates recursively (direct reports + their reports)
-
1
def all_subordinates
-
direct = subordinates.to_a
-
indirect = direct.flat_map(&:all_subordinates)
-
direct + indirect
-
end
-
-
# Check if has sufficient vacation balance
-
1
def has_vacation_balance?(days) # rubocop:disable Naming/PredicatePrefix
-
10
available_vacation_days >= days
-
end
-
-
# Deduct vacation days (called when request is approved)
-
1
def deduct_vacation!(days)
-
4
raise InsufficientBalanceError, "Insufficient vacation balance" unless has_vacation_balance?(days)
-
-
3
self.vacation_balance_days -= days
-
3
self.vacation_used_ytd += days
-
3
save!
-
end
-
-
# Restore vacation days (called when approved request is cancelled)
-
1
def restore_vacation!(days)
-
2
self.vacation_balance_days += days
-
2
self.vacation_used_ytd -= days
-
2
save!
-
end
-
-
# Mock: Accrue vacation days (would be called by payroll integration)
-
1
def accrue_vacation!(days)
-
1
self.vacation_balance_days += days
-
1
self.vacation_accrued_ytd += days
-
1
save!
-
end
-
-
# Get pending vacation requests
-
1
def pending_vacation_requests
-
vacation_requests.pending
-
end
-
-
# Get approved vacation days for a date range
-
1
def approved_vacation_days_in_range(start_date, end_date)
-
vacation_requests
-
.approved
-
.overlapping(start_date, end_date)
-
.sum(&:business_days)
-
end
-
-
# ============================================
-
# Vacation Balance Calculations (Ley Colombiana)
-
# 15 días hábiles por año trabajado
-
# ============================================
-
-
# Días acumulados según antigüedad
-
1
def accrued_vacation_days
-
10
return 0.0 unless hire_date
-
-
10
years_of_service = (Date.current - hire_date).to_f / 365.25
-
10
(years_of_service * 15).round(2)
-
end
-
-
# Días programados (aprobados pero aún no disfrutados)
-
1
def scheduled_vacation_days
-
vacation_requests
-
.approved
-
.sum(:days_requested)
-
end
-
-
# Días ya disfrutados
-
1
def enjoyed_vacation_days
-
vacation_requests
-
.enjoyed
-
.sum(:days_requested)
-
end
-
-
# Total de días usados (programados + disfrutados)
-
1
def total_used_vacation_days
-
10
vacation_requests
-
.used
-
.sum(:days_requested)
-
end
-
-
# Días disponibles totales (acumulados - usados)
-
1
def available_vacation_days
-
10
(accrued_vacation_days - total_used_vacation_days).round(2)
-
end
-
-
# Días disponibles sin programación (para nuevas solicitudes)
-
1
def available_for_request
-
available_vacation_days
-
end
-
-
# Días disponibles reales (excluyendo los ya programados)
-
1
def truly_available_days
-
(accrued_vacation_days - enjoyed_vacation_days).round(2)
-
end
-
-
# Resumen de vacaciones
-
1
def vacation_summary
-
{
-
accrued: accrued_vacation_days,
-
scheduled: scheduled_vacation_days,
-
enjoyed: enjoyed_vacation_days,
-
total_used: total_used_vacation_days,
-
available: available_vacation_days,
-
truly_available: truly_available_days
-
}
-
end
-
-
# Get supervisor chain (for escalation)
-
1
def supervisor_chain
-
chain = []
-
current = supervisor
-
while current
-
chain << current
-
current = current.supervisor
-
end
-
chain
-
end
-
-
1
private
-
-
# Sync employee name to associated user account
-
1
def sync_user_name
-
return unless user
-
-
user.update(
-
first_name: first_name,
-
last_name: last_name
-
)
-
end
-
-
# Only sync if name fields changed and user exists
-
1
def should_sync_user_name?
-
29
user_id.present? && (saved_change_to_first_name? || saved_change_to_last_name?)
-
end
-
-
1
class << self
-
# Find employee by user
-
1
def for_user(user)
-
where(user_id: user.id).first
-
end
-
-
# Find or create employee for user
-
1
def find_or_create_for_user!(user, attributes = {})
-
where(user_id: user.id).first || create!(
-
attributes.merge(
-
user: user,
-
organization: user.organization
-
)
-
)
-
end
-
-
# Mock: Initialize vacation balances for new year
-
1
def reset_vacation_balances_for_year!(organization, default_days: 15)
-
active.where(organization_id: organization.id).find_each do |employee|
-
carry_over = [employee.vacation_balance_days, 5].min # Max 5 days carry over
-
employee.update!(
-
vacation_carry_over: carry_over,
-
vacation_balance_days: default_days + carry_over,
-
vacation_accrued_ytd: 0,
-
vacation_used_ytd: 0
-
)
-
end
-
end
-
end
-
-
1
class InsufficientBalanceError < StandardError; end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Hr
-
# Employment certification request (constancia laboral)
-
# Can be requested by employee, processed by HR
-
#
-
# rubocop:disable Metrics/ClassLength
-
class EmploymentCertificationRequest
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "hr_certification_requests"
-
-
# Status constants
-
STATUS_PENDING = "pending"
-
STATUS_PROCESSING = "processing"
-
STATUS_COMPLETED = "completed"
-
STATUS_REJECTED = "rejected"
-
STATUS_CANCELLED = "cancelled"
-
-
STATUSES = [STATUS_PENDING, STATUS_PROCESSING, STATUS_COMPLETED, STATUS_REJECTED, STATUS_CANCELLED].freeze
-
-
# Certification type constants
-
TYPE_EMPLOYMENT = "employment" # Basic employment verification
-
TYPE_SALARY = "salary" # With salary information
-
TYPE_POSITION = "position" # Position/role details
-
TYPE_FULL = "full" # Complete employment details
-
TYPE_CUSTOM = "custom" # Custom content requested
-
-
CERTIFICATION_TYPES = [TYPE_EMPLOYMENT, TYPE_SALARY, TYPE_POSITION, TYPE_FULL, TYPE_CUSTOM].freeze
-
-
# Purpose constants (why they need the letter)
-
PURPOSE_BANK = "bank" # Bank loan/credit
-
PURPOSE_VISA = "visa" # Visa application
-
PURPOSE_RENTAL = "rental" # Rental application
-
PURPOSE_GOVERNMENT = "government" # Government procedures
-
PURPOSE_LEGAL = "legal" # Legal proceedings
-
PURPOSE_OTHER = "other"
-
-
PURPOSES = [PURPOSE_BANK, PURPOSE_VISA, PURPOSE_RENTAL, PURPOSE_GOVERNMENT, PURPOSE_LEGAL, PURPOSE_OTHER].freeze
-
-
# Fields
-
field :request_number, type: String
-
field :certification_type, type: String, default: TYPE_EMPLOYMENT
-
field :purpose, type: String, default: PURPOSE_OTHER
-
field :purpose_details, type: String
-
field :addressee, type: String # "To whom it may concern" or specific
-
field :language, type: String, default: "es" # es, en
-
field :special_instructions, type: String
-
field :status, type: String, default: STATUS_PENDING
-
-
# Include specific data (for salary type)
-
field :include_salary, type: Boolean, default: false
-
field :include_start_date, type: Boolean, default: true
-
field :include_position, type: Boolean, default: true
-
field :include_department, type: Boolean, default: false
-
-
# Processing fields
-
field :submitted_at, type: Time
-
field :processed_at, type: Time
-
field :completed_at, type: Time
-
field :rejection_reason, type: String
-
field :processor_notes, type: String
-
-
# Generated document reference
-
field :document_uuid, type: String # Reference to generated PDF document
-
field :pickup_date, type: Date
-
field :delivery_method, type: String, default: "digital" # digital, physical, both
-
-
# History tracking
-
field :history, type: Array, default: []
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ request_number: 1 }, { unique: true, sparse: true })
-
index({ employee_id: 1 })
-
index({ processed_by_id: 1 })
-
index({ organization_id: 1 })
-
index({ status: 1 })
-
index({ organization_id: 1, status: 1 })
-
index({ employee_id: 1, status: 1 })
-
-
# Associations
-
belongs_to :employee, class_name: "Hr::Employee"
-
belongs_to :processed_by, class_name: "Hr::Employee", optional: true
-
belongs_to :organization, class_name: "Identity::Organization"
-
-
# Validations
-
validates :certification_type, inclusion: { in: CERTIFICATION_TYPES }
-
validates :purpose, inclusion: { in: PURPOSES }
-
validates :status, inclusion: { in: STATUSES }
-
validates :language, inclusion: { in: ["es", "en"] }
-
validate :salary_permission_required, if: :include_salary?
-
-
# Callbacks
-
before_create :generate_request_number
-
before_create :set_submitted_at
-
after_create :log_request_created
-
-
# Scopes
-
scope :pending, -> { where(status: STATUS_PENDING) }
-
scope :processing, -> { where(status: STATUS_PROCESSING) }
-
scope :completed, -> { where(status: STATUS_COMPLETED) }
-
scope :rejected, -> { where(status: STATUS_REJECTED) }
-
scope :cancelled, -> { where(status: STATUS_CANCELLED) }
-
scope :for_processing, -> { where(:status.in => [STATUS_PENDING, STATUS_PROCESSING]) }
-
-
# State predicates
-
def pending?
-
status == STATUS_PENDING
-
end
-
-
def processing?
-
status == STATUS_PROCESSING
-
end
-
-
def completed?
-
status == STATUS_COMPLETED
-
end
-
-
def rejected?
-
status == STATUS_REJECTED
-
end
-
-
def cancelled?
-
status == STATUS_CANCELLED
-
end
-
-
# Start processing (by HR)
-
def start_processing!(actor:)
-
raise InvalidStateError, "Can only process pending requests" unless pending?
-
raise AuthorizationError, "Only HR staff can process requests" unless actor.hr_staff?
-
-
self.status = STATUS_PROCESSING
-
self.processed_by = actor
-
self.processed_at = Time.current
-
-
record_history("processing_started", actor)
-
save!
-
-
log_audit_event("certification_processing_started", actor)
-
-
self
-
end
-
-
# Complete request (by HR)
-
# rubocop:disable Naming/PredicateMethod
-
def complete!(actor:, document_uuid: nil, notes: nil)
-
raise InvalidStateError, "Can only complete processing requests" unless processing?
-
raise AuthorizationError, "Only HR staff can complete requests" unless actor.hr_staff?
-
-
self.status = STATUS_COMPLETED
-
self.completed_at = Time.current
-
self.document_uuid = document_uuid
-
self.processor_notes = notes
-
-
record_history("completed", actor, notes)
-
save!
-
-
log_audit_event("certification_completed", actor, { document_uuid: document_uuid })
-
-
true
-
end
-
-
# Reject request (by HR)
-
def reject!(actor:, reason:)
-
raise InvalidStateError, "Cannot reject completed requests" if completed?
-
raise AuthorizationError, "Only HR staff can reject requests" unless actor.hr_staff?
-
raise ValidationError, "Rejection reason is required" if reason.blank?
-
-
self.status = STATUS_REJECTED
-
self.rejection_reason = reason
-
self.completed_at = Time.current
-
-
record_history("rejected", actor, reason)
-
save!
-
-
log_audit_event("certification_rejected", actor, { reason: reason })
-
-
true
-
end
-
-
# Cancel request (by employee)
-
def cancel!(actor:, reason: nil)
-
raise InvalidStateError, "Cannot cancel completed or rejected requests" if completed? || rejected?
-
raise AuthorizationError, "Only the employee can cancel their request" unless can_cancel?(actor)
-
-
self.status = STATUS_CANCELLED
-
-
record_history("cancelled", actor, reason)
-
save!
-
-
log_audit_event("certification_cancelled", actor, { reason: reason })
-
-
true
-
end
-
-
# Check if actor can view this request
-
def can_view?(actor)
-
return true if actor.hr_staff?
-
return true if actor.id == employee.id
-
return true if actor.supervises?(employee)
-
-
false
-
end
-
-
# Check if actor can cancel this request
-
def can_cancel?(actor)
-
return false if completed? || rejected?
-
return true if actor.hr_staff?
-
-
actor.id == employee.id
-
end
-
-
# rubocop:enable Naming/PredicateMethod
-
-
# Estimated processing time in days
-
# rubocop:disable Lint/DuplicateBranch
-
def estimated_days
-
case certification_type
-
when TYPE_EMPLOYMENT, TYPE_POSITION
-
1
-
when TYPE_SALARY
-
2
-
when TYPE_FULL, TYPE_CUSTOM
-
3
-
else
-
1 # Default to shortest time for unknown types
-
end
-
end
-
# rubocop:enable Lint/DuplicateBranch
-
-
# Generate certification content (for preview/generation)
-
def certification_content
-
{
-
employee_name: employee.full_name,
-
employee_number: employee.employee_number,
-
job_title: include_position? ? employee.job_title : nil,
-
department: include_department? ? employee.department : nil,
-
hire_date: include_start_date? ? employee.hire_date : nil,
-
employment_status: employee.employment_status,
-
employment_type: employee.employment_type,
-
addressee: addressee || "A quien corresponda",
-
language: language,
-
certification_type: certification_type,
-
purpose: purpose,
-
generated_at: Time.current
-
}.compact
-
end
-
-
private
-
-
def salary_permission_required
-
return unless include_salary? && certification_type != TYPE_SALARY
-
-
errors.add(:include_salary, "requires salary certification type") unless certification_type == TYPE_FULL
-
end
-
-
def generate_request_number
-
return if request_number.present?
-
-
year = Date.current.year
-
sequence = EmploymentCertificationRequest
-
.where(organization_id: organization_id)
-
.where(:created_at.gte => Date.new(year, 1, 1))
-
.count + 1
-
-
self.request_number = "CERT-#{year}-#{sequence.to_s.rjust(5, "0")}"
-
end
-
-
def set_submitted_at
-
self.submitted_at ||= Time.current
-
end
-
-
def record_history(action, actor, details = nil)
-
history << {
-
"action" => action,
-
"at" => Time.current.iso8601,
-
"actor_id" => actor&.id&.to_s,
-
"actor_name" => actor&.full_name,
-
"details" => details
-
}.compact
-
end
-
-
def log_request_created
-
log_audit_event("certification_request_created", employee)
-
end
-
-
def log_audit_event(action, actor, metadata = {})
-
Audit::AuditEvent.log(
-
event_type: "hr",
-
action: action,
-
target: self,
-
actor: actor&.user,
-
metadata: metadata.merge(
-
request_number: request_number,
-
employee_name: employee.full_name,
-
certification_type: certification_type,
-
purpose: purpose
-
),
-
tags: ["hr", "certification", action]
-
)
-
end
-
-
class InvalidStateError < StandardError; end
-
class AuthorizationError < StandardError; end
-
class ValidationError < StandardError; end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
1
module Hr
-
# Vacation/PTO request with approval workflow
-
# Requires supervisor approval, enforces balance rules
-
#
-
# rubocop:disable Metrics/ClassLength
-
1
class VacationRequest
-
1
include Mongoid::Document
-
1
include Mongoid::Timestamps
-
1
include UuidIdentifiable
-
-
1
store_in collection: "hr_vacation_requests"
-
-
# Status constants
-
1
STATUS_DRAFT = "draft"
-
1
STATUS_PENDING = "pending" # Solicitada
-
1
STATUS_APPROVED = "approved" # Aprobada (programada, aún no disfrutada)
-
1
STATUS_ENJOYED = "enjoyed" # Disfrutada (vacaciones ya tomadas)
-
1
STATUS_REJECTED = "rejected"
-
1
STATUS_CANCELLED = "cancelled"
-
-
1
STATUSES = [STATUS_DRAFT, STATUS_PENDING, STATUS_APPROVED, STATUS_ENJOYED, STATUS_REJECTED, STATUS_CANCELLED].freeze
-
-
STATUS_LABELS = {
-
1
STATUS_DRAFT => "Borrador",
-
STATUS_PENDING => "Solicitada",
-
STATUS_APPROVED => "Aprobada",
-
STATUS_ENJOYED => "Disfrutada",
-
STATUS_REJECTED => "Rechazada",
-
STATUS_CANCELLED => "Cancelada"
-
}.freeze
-
-
# Vacation type constants
-
1
TYPE_VACATION = "vacation"
-
1
TYPE_PERSONAL = "personal"
-
1
TYPE_SICK = "sick"
-
1
TYPE_BEREAVEMENT = "bereavement"
-
1
TYPE_UNPAID = "unpaid"
-
-
1
VACATION_TYPES = [TYPE_VACATION, TYPE_PERSONAL, TYPE_SICK, TYPE_BEREAVEMENT, TYPE_UNPAID].freeze
-
-
# Fields
-
1
field :request_number, type: String
-
1
field :vacation_type, type: String, default: TYPE_VACATION
-
1
field :start_date, type: Date
-
1
field :end_date, type: Date
-
1
field :days_requested, type: Float
-
1
field :reason, type: String
-
1
field :status, type: String, default: STATUS_DRAFT
-
1
field :notes, type: String
-
-
# Approval fields
-
1
field :submitted_at, type: Time
-
1
field :decided_at, type: Time
-
1
field :decision_reason, type: String
-
1
field :approved_by_name, type: String
-
-
# Generated document reference
-
1
field :document_uuid, type: String
-
-
# History tracking
-
1
field :history, type: Array, default: []
-
-
# Indexes
-
1
index({ uuid: 1 }, { unique: true })
-
1
index({ request_number: 1 }, { unique: true, sparse: true })
-
1
index({ employee_id: 1 })
-
1
index({ approver_id: 1 })
-
1
index({ organization_id: 1 })
-
1
index({ status: 1 })
-
1
index({ start_date: 1 })
-
1
index({ end_date: 1 })
-
1
index({ organization_id: 1, status: 1 })
-
1
index({ employee_id: 1, status: 1 })
-
1
index({ approver_id: 1, status: 1 })
-
-
# Associations
-
1
belongs_to :employee, class_name: "Hr::Employee"
-
1
belongs_to :approver, class_name: "Hr::Employee", optional: true
-
1
belongs_to :organization, class_name: "Identity::Organization"
-
-
# Validations
-
1
validates :vacation_type, inclusion: { in: VACATION_TYPES }
-
1
validates :status, inclusion: { in: STATUSES }
-
1
validates :start_date, presence: true
-
1
validates :end_date, presence: true
-
1
validates :days_requested, presence: true, numericality: { greater_than: 0 }
-
1
validate :end_date_after_start_date
-
1
validate :dates_not_in_past, on: :create
-
1
validate :sufficient_balance, on: :create, if: :requires_balance_check?
-
1
validate :no_overlapping_requests, if: :dates_changed?
-
-
# Callbacks
-
1
before_create :generate_request_number
-
1
after_create :log_request_created
-
-
# Scopes
-
1
scope :draft, -> { where(status: STATUS_DRAFT) }
-
1
scope :pending, -> { where(status: STATUS_PENDING) }
-
1
scope :approved, -> { where(status: STATUS_APPROVED) }
-
1
scope :enjoyed, -> { where(status: STATUS_ENJOYED) }
-
1
scope :rejected, -> { where(status: STATUS_REJECTED) }
-
1
scope :cancelled, -> { where(status: STATUS_CANCELLED) }
-
1
scope :active, -> { where(:status.in => [STATUS_PENDING, STATUS_APPROVED]) }
-
1
scope :decided, -> { where(:status.in => [STATUS_APPROVED, STATUS_REJECTED]) }
-
1
scope :scheduled, -> { approved.where(:start_date.gt => Date.current) } # Programadas (futuras)
-
1
scope :in_progress, -> { approved.where(:start_date.lte => Date.current, :end_date.gte => Date.current) }
-
11
scope :used, -> { where(:status.in => [STATUS_APPROVED, STATUS_ENJOYED]) } # Consumen balance
-
1
scope :for_approval_by, ->(employee) { pending.where(approver_id: employee.id) }
-
1
scope :upcoming, -> { approved.where(:start_date.gte => Date.current) }
-
1
scope :past, -> { approved.where(:end_date.lt => Date.current) }
-
1
scope :current, -> { approved.where(:start_date.lte => Date.current, :end_date.gte => Date.current) }
-
1
scope :in_date_range, ->(start_d, end_d) { where(:start_date.lte => end_d, :end_date.gte => start_d) }
-
1
scope :overlapping, ->(start_d, end_d) { approved.in_date_range(start_d, end_d) }
-
-
# State predicates
-
1
def draft?
-
status == STATUS_DRAFT
-
end
-
-
1
def pending?
-
2
status == STATUS_PENDING
-
end
-
-
1
def approved?
-
1
status == STATUS_APPROVED
-
end
-
-
1
def rejected?
-
1
status == STATUS_REJECTED
-
end
-
-
1
def cancelled?
-
status == STATUS_CANCELLED
-
end
-
-
1
def enjoyed?
-
status == STATUS_ENJOYED
-
end
-
-
1
def decided?
-
approved? || rejected?
-
end
-
-
1
def status_label
-
STATUS_LABELS[status] || status
-
end
-
-
# Check if vacation period has passed and should be marked as enjoyed
-
1
def should_mark_as_enjoyed?
-
approved? && end_date < Date.current
-
end
-
-
# Submit request for approval
-
1
def submit!(actor:)
-
raise InvalidStateError, "Can only submit draft requests" unless draft?
-
raise ValidationError, "Insufficient vacation balance" unless has_sufficient_balance?
-
-
self.status = STATUS_PENDING
-
self.submitted_at = Time.current
-
self.approver = determine_approver
-
-
record_history("submitted", actor)
-
save!
-
-
log_audit_event("vacation_request_submitted", actor)
-
-
self
-
end
-
-
# Approve request (by supervisor or HR)
-
# rubocop:disable Naming/PredicateMethod
-
1
def approve!(actor:, reason: nil)
-
1
raise InvalidStateError, "Can only approve pending requests" unless pending?
-
1
raise AuthorizationError, "Not authorized to approve" unless can_approve?(actor)
-
-
1
self.status = STATUS_APPROVED
-
1
self.decided_at = Time.current
-
1
self.decision_reason = reason
-
1
self.approved_by_name = actor.full_name
-
-
# Deduct vacation balance
-
1
employee.deduct_vacation!(days_requested) if deducts_balance?
-
-
1
record_history("approved", actor, reason)
-
1
save!
-
-
1
log_audit_event("vacation_request_approved", actor, { reason: reason })
-
-
1
true
-
end
-
-
# Reject request
-
1
def reject!(actor:, reason:)
-
1
raise InvalidStateError, "Can only reject pending requests" unless pending?
-
1
raise AuthorizationError, "Not authorized to reject" unless can_approve?(actor)
-
1
raise ValidationError, "Rejection reason is required" if reason.blank?
-
-
1
self.status = STATUS_REJECTED
-
1
self.decided_at = Time.current
-
1
self.decision_reason = reason
-
-
1
record_history("rejected", actor, reason)
-
1
save!
-
-
1
log_audit_event("vacation_request_rejected", actor, { reason: reason })
-
-
1
true
-
end
-
-
# Cancel request (by employee or HR)
-
1
def cancel!(actor:, reason: nil)
-
1
raise InvalidStateError, "Cannot cancel decided requests" if rejected?
-
-
1
was_approved = approved?
-
-
1
self.status = STATUS_CANCELLED
-
1
self.decided_at = Time.current
-
1
self.decision_reason = reason
-
-
# Restore vacation balance if was approved
-
1
employee.restore_vacation!(days_requested) if was_approved && deducts_balance?
-
-
1
record_history("cancelled", actor, reason)
-
1
save!
-
-
1
log_audit_event("vacation_request_cancelled", actor, {
-
reason: reason,
-
was_approved: was_approved
-
})
-
-
1
true
-
end
-
-
# Mark vacation as enjoyed (after end_date has passed)
-
1
def mark_as_enjoyed!(actor: nil)
-
raise InvalidStateError, "Can only mark approved vacations as enjoyed" unless approved?
-
raise InvalidStateError, "Cannot mark as enjoyed before end date" if end_date >= Date.current
-
-
self.status = STATUS_ENJOYED
-
-
record_history("enjoyed", actor)
-
save!
-
-
log_audit_event("vacation_request_enjoyed", actor) if actor
-
-
true
-
end
-
-
# Class method to auto-mark past approved vacations as enjoyed
-
1
def self.mark_past_vacations_as_enjoyed!
-
approved.where(:end_date.lt => Date.current).each do |vacation|
-
vacation.mark_as_enjoyed!
-
rescue InvalidStateError
-
# Skip if already processed
-
next
-
end
-
end
-
-
# Check if actor can approve this request
-
1
def can_approve?(actor)
-
# Must be in same organization
-
2
return false unless actor.organization_id == organization_id
-
-
2
return true if actor.hr_manager?
-
2
return true if actor.hr_staff? && employee.supervisor.nil?
-
-
2
actor.supervises?(employee)
-
end
-
-
# Check if actor can view this request
-
1
def can_view?(actor)
-
return true if actor.hr_staff?
-
return true if actor.id == employee.id
-
return true if actor.supervises?(employee)
-
-
employee.supervisor_chain.include?(actor)
-
end
-
-
# Check if actor can cancel this request
-
# rubocop:disable Metrics/PerceivedComplexity
-
1
def can_cancel?(actor)
-
return false if rejected?
-
return true if actor.hr_manager?
-
return true if actor.id == employee.id && (draft? || pending?)
-
return true if actor.id == employee.id && approved? && start_date > Date.current
-
-
false
-
end
-
# rubocop:enable Metrics/PerceivedComplexity
-
# rubocop:enable Naming/PredicateMethod
-
-
# Calculate business days (simplified - excludes weekends)
-
1
def business_days
-
return days_requested if days_requested
-
-
count = 0
-
(start_date..end_date).each do |date|
-
count += 1 unless date.saturday? || date.sunday?
-
end
-
count
-
end
-
-
1
private
-
-
1
def end_date_after_start_date
-
9
return unless start_date && end_date
-
-
9
errors.add(:end_date, "must be after or equal to start date") if end_date < start_date
-
end
-
-
1
def dates_not_in_past
-
4
return unless start_date
-
-
# Allow 1 day tolerance for timezone differences (UTC vs local time)
-
4
errors.add(:start_date, "cannot be in the past") if start_date < Date.current - 1.day
-
end
-
-
1
def no_overlapping_requests
-
4
return unless employee && start_date && end_date
-
-
# Find other requests from the same employee that overlap with these dates
-
# Exclude cancelled and rejected requests, and exclude self (for updates)
-
4
overlapping = employee.vacation_requests
-
.where(:status.nin => [STATUS_CANCELLED, STATUS_REJECTED])
-
.where(:id.ne => id)
-
.where(:start_date.lte => end_date, :end_date.gte => start_date)
-
-
4
return unless overlapping.exists?
-
-
overlapping_request = overlapping.first
-
errors.add(:base, "Ya tienes una solicitud (#{overlapping_request.request_number}) que incluye estas fechas")
-
end
-
-
1
def dates_changed?
-
9
new_record? || start_date_changed? || end_date_changed?
-
end
-
-
1
def sufficient_balance
-
4
return unless employee && days_requested
-
-
4
return if has_sufficient_balance?
-
-
1
errors.add(:days_requested, "exceeds available vacation balance")
-
end
-
-
1
def has_sufficient_balance? # rubocop:disable Naming/PredicatePrefix
-
4
return true unless requires_balance_check?
-
-
4
employee.has_vacation_balance?(days_requested)
-
end
-
-
1
def requires_balance_check?
-
8
[TYPE_VACATION, TYPE_PERSONAL].include?(vacation_type)
-
end
-
-
1
def deducts_balance?
-
2
[TYPE_VACATION, TYPE_PERSONAL].include?(vacation_type)
-
end
-
-
1
def determine_approver
-
employee.supervisor || find_hr_manager
-
end
-
-
1
def find_hr_manager
-
Hr::Employee
-
.where(organization_id: organization_id)
-
.active
-
.detect(&:hr_manager?)
-
end
-
-
1
def generate_request_number
-
3
return if request_number.present?
-
-
3
year = Date.current.year
-
3
sequence = VacationRequest.where(organization_id: organization_id)
-
.where(:created_at.gte => Date.new(year, 1, 1))
-
.count + 1
-
-
3
self.request_number = "VAC-#{year}-#{sequence.to_s.rjust(5, "0")}"
-
end
-
-
1
def record_history(action, actor, details = nil)
-
3
history << {
-
"action" => action,
-
"at" => Time.current.iso8601,
-
"actor_id" => actor&.id&.to_s,
-
"actor_name" => actor&.full_name,
-
"details" => details
-
}.compact
-
end
-
-
1
def log_request_created
-
3
log_audit_event("vacation_request_created", employee)
-
end
-
-
1
def log_audit_event(action, actor, metadata = {})
-
6
Audit::AuditEvent.log(
-
event_type: "hr",
-
action: action,
-
target: self,
-
actor: actor&.user,
-
metadata: metadata.merge(
-
request_number: request_number,
-
employee_name: employee.full_name,
-
vacation_type: vacation_type,
-
days_requested: days_requested,
-
start_date: start_date&.iso8601,
-
end_date: end_date&.iso8601
-
),
-
tags: ["hr", "vacation", action]
-
)
-
end
-
-
1
class InvalidStateError < StandardError; end
-
1
class AuthorizationError < StandardError; end
-
1
class ValidationError < StandardError; end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
1
module Identity
-
1
class JwtDenylist
-
1
include Mongoid::Document
-
1
include Mongoid::Timestamps::Created
-
-
1
store_in collection: "jwt_denylists"
-
-
1
field :jti, type: String
-
1
field :exp, type: Time
-
-
1
index({ jti: 1 }, { unique: true })
-
1
index({ exp: 1 }, { expire_after_seconds: 0 })
-
-
1
validates :jti, presence: true, uniqueness: true
-
-
1
class << self
-
1
def jwt_revoked?(payload, _user)
-
exists?(jti: payload["jti"])
-
end
-
-
1
def revoke_jwt(payload, _user)
-
find_or_create_by(jti: payload["jti"]) do |record|
-
record.exp = Time.zone.at(payload["exp"].to_i)
-
end
-
end
-
-
1
def cleanup_expired!
-
where(:exp.lt => Time.current).delete_all
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Identity
-
1
class Organization
-
1
include Mongoid::Document
-
1
include Mongoid::Timestamps
-
1
include UuidIdentifiable
-
1
include SoftDeletable
-
1
include AuditTrackable
-
-
1
store_in collection: "organizations"
-
-
# Fields
-
1
field :name, type: String
-
1
field :slug, type: String
-
1
field :settings, type: Hash, default: {}
-
1
field :active, type: Boolean, default: true
-
-
# Organization details
-
1
field :legal_name, type: String
-
1
field :tax_id, type: String # NIT
-
1
field :address, type: String
-
1
field :city, type: String
-
1
field :country, type: String, default: 'Colombia'
-
1
field :phone, type: String
-
1
field :email, type: String
-
1
field :website, type: String
-
1
field :logo_url, type: String
-
-
# HR Settings
-
1
field :vacation_days_per_year, type: Integer, default: 15
-
1
field :vacation_accrual_policy, type: String, default: 'monthly' # monthly, yearly
-
1
field :max_vacation_carryover, type: Integer, default: 15
-
1
field :probation_period_months, type: Integer, default: 2
-
-
# Document Settings
-
1
field :allowed_file_types, type: Array, default: %w[pdf docx xlsx pptx jpg png]
-
1
field :max_file_size_mb, type: Integer, default: 25
-
1
field :document_retention_years, type: Integer, default: 10
-
-
# Security Settings
-
1
field :session_timeout_minutes, type: Integer, default: 480
-
1
field :password_min_length, type: Integer, default: 8
-
1
field :password_require_uppercase, type: Boolean, default: true
-
1
field :password_require_number, type: Boolean, default: true
-
1
field :password_require_special, type: Boolean, default: false
-
1
field :max_login_attempts, type: Integer, default: 5
-
-
# Indexes
-
1
index({ slug: 1 }, { unique: true })
-
1
index({ name: 1 })
-
1
index({ active: 1 })
-
-
# Associations
-
1
has_many :users, class_name: "Identity::User", inverse_of: :organization
-
1
has_many :third_party_types, class_name: "Legal::ThirdPartyType", dependent: :destroy
-
-
# Validations
-
1
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
-
1
validates :slug, presence: true, uniqueness: true,
-
format: { with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
-
-
# Callbacks
-
1
before_validation :generate_slug, on: :create
-
-
# Scopes
-
42
scope :active, -> { where(active: true) }
-
-
1
def activate!
-
update!(active: true)
-
end
-
-
1
def deactivate!
-
update!(active: false)
-
end
-
-
1
private
-
-
1
def generate_slug
-
13
return if slug.present?
-
-
base_slug = name.to_s.parameterize
-
self.slug = base_slug
-
-
counter = 1
-
while Identity::Organization.exists?(slug: slug)
-
self.slug = "#{base_slug}-#{counter}"
-
counter += 1
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Identity
-
class Permission
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
-
store_in collection: "permissions"
-
-
# Fields
-
field :name, type: String
-
field :resource, type: String
-
field :action, type: String
-
field :description, type: String
-
-
# Indexes
-
index({ name: 1 }, { unique: true })
-
index({ resource: 1, action: 1 }, { unique: true })
-
-
# Associations
-
has_and_belongs_to_many :roles, class_name: "Identity::Role", inverse_of: :permissions
-
-
# Validations
-
validates :name, presence: true, uniqueness: true
-
validates :resource, presence: true
-
validates :action, presence: true
-
validates :resource, uniqueness: { scope: :action }
-
-
# Standard actions
-
ACTIONS = ["create", "read", "update", "delete", "manage", "export"].freeze
-
-
# Standard resources
-
RESOURCES = [
-
"users", "roles", "organizations", "documents", "folders",
-
"workflows", "audit_logs", "settings", "hr_requests", "legal_documents"
-
].freeze
-
-
scope :for_resource, ->(resource) { where(resource: resource) }
-
scope :for_action, ->(action) { where(action: action) }
-
-
class << self
-
def seed_defaults!
-
default_permissions.each do |attrs|
-
find_or_create_by!(name: attrs[:name]) do |p|
-
p.resource = attrs[:resource]
-
p.action = attrs[:action]
-
p.description = attrs[:description]
-
end
-
end
-
end
-
-
private
-
-
def default_permissions
-
[
-
# User management
-
{ name: "users.read", resource: "users", action: "read", description: "View users" },
-
{ name: "users.create", resource: "users", action: "create", description: "Create users" },
-
{ name: "users.update", resource: "users", action: "update", description: "Update users" },
-
{ name: "users.delete", resource: "users", action: "delete", description: "Delete users" },
-
{ name: "users.manage", resource: "users", action: "manage", description: "Full user management" },
-
-
# Document management
-
{ name: "documents.read", resource: "documents", action: "read", description: "View documents" },
-
{ name: "documents.create", resource: "documents", action: "create", description: "Create documents" },
-
{ name: "documents.update", resource: "documents", action: "update", description: "Update documents" },
-
{ name: "documents.delete", resource: "documents", action: "delete", description: "Delete documents" },
-
{ name: "documents.manage", resource: "documents", action: "manage",
-
description: "Full document management" },
-
{ name: "documents.export", resource: "documents", action: "export", description: "Export documents" },
-
-
# Admin settings
-
{ name: "settings.read", resource: "settings", action: "read", description: "View settings" },
-
{ name: "settings.manage", resource: "settings", action: "manage", description: "Manage settings" },
-
-
# HR management
-
{ name: "hr_requests.read", resource: "hr_requests", action: "read", description: "View HR requests" },
-
{ name: "hr_requests.manage", resource: "hr_requests", action: "manage", description: "Manage HR requests" },
-
-
# Legal documents
-
{ name: "legal_documents.read", resource: "legal_documents", action: "read", description: "View legal docs" },
-
{ name: "legal_documents.manage", resource: "legal_documents", action: "manage",
-
description: "Manage legal docs" },
-
-
# Audit logs
-
{ name: "audit_logs.read", resource: "audit_logs", action: "read", description: "View audit logs" }
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Identity
-
1
class Role
-
1
include Mongoid::Document
-
1
include Mongoid::Timestamps
-
-
1
store_in collection: "roles"
-
-
# Predefined role names
-
1
ADMIN = "admin"
-
1
CEO = "ceo"
-
1
GENERAL_MANAGER = "general_manager"
-
1
LEGAL_REPRESENTATIVE = "legal_representative"
-
1
LEGAL = "legal"
-
1
HR_MANAGER = "hr_manager"
-
1
HR = "hr"
-
1
ACCOUNTANT = "accountant"
-
1
MANAGER = "manager"
-
1
EMPLOYEE = "employee"
-
1
VIEWER = "viewer"
-
-
1
ALL_ROLES = [ADMIN, CEO, GENERAL_MANAGER, LEGAL_REPRESENTATIVE, LEGAL, HR_MANAGER, HR, ACCOUNTANT, MANAGER, EMPLOYEE, VIEWER].freeze
-
-
# Permission levels (1-5 scale)
-
1
LEVEL_ADMIN = 5 # Full system access
-
1
LEVEL_LEGAL = 4 # Legal department access
-
1
LEVEL_HR = 3 # HR department access
-
1
LEVEL_EMPLOYEE = 2 # Standard employee access
-
1
LEVEL_VIEWER = 1 # Read-only access
-
-
# Level to role mapping
-
LEVELS = {
-
1
LEVEL_ADMIN => ADMIN,
-
LEVEL_LEGAL => LEGAL,
-
LEVEL_HR => HR,
-
LEVEL_EMPLOYEE => EMPLOYEE,
-
LEVEL_VIEWER => VIEWER
-
}.freeze
-
-
# Role to level mapping
-
ROLE_LEVELS = {
-
1
ADMIN => LEVEL_ADMIN,
-
CEO => LEVEL_ADMIN,
-
GENERAL_MANAGER => LEVEL_ADMIN,
-
LEGAL_REPRESENTATIVE => LEVEL_ADMIN,
-
LEGAL => LEVEL_LEGAL,
-
HR_MANAGER => LEVEL_LEGAL,
-
HR => LEVEL_HR,
-
ACCOUNTANT => LEVEL_HR,
-
MANAGER => LEVEL_HR,
-
EMPLOYEE => LEVEL_EMPLOYEE,
-
VIEWER => LEVEL_VIEWER
-
}.freeze
-
-
# Fields
-
1
field :name, type: String
-
1
field :display_name, type: String
-
1
field :description, type: String
-
1
field :system_role, type: Boolean, default: false
-
1
field :level, type: Integer, default: 0
-
-
# Indexes
-
1
index({ name: 1 }, { unique: true })
-
1
index({ level: -1 })
-
-
# Associations
-
1
has_and_belongs_to_many :users, class_name: "Identity::User", inverse_of: :roles
-
1
has_and_belongs_to_many :permissions, class_name: "Identity::Permission", inverse_of: :roles
-
-
# Validations
-
1
validates :name, presence: true, uniqueness: true
-
1
validates :display_name, presence: true
-
-
# Scopes
-
1
scope :system_roles, -> { where(system_role: true) }
-
1
scope :custom_roles, -> { where(system_role: false) }
-
1
scope :by_level, -> { order(level: :desc) }
-
-
1
def admin?
-
name == ADMIN
-
end
-
-
1
def has_permission?(permission_name)
-
permissions.exists?(name: permission_name)
-
end
-
-
1
def can?(action, resource)
-
# Admin can do everything
-
return true if admin?
-
-
# Check for manage permission (implies all actions)
-
return true if permissions.exists?(resource: resource, action: "manage")
-
-
# Check for specific permission
-
permissions.exists?(resource: resource, action: action)
-
end
-
-
1
def permission_names
-
permissions.pluck(:name)
-
end
-
-
# Level comparison methods
-
1
def level_value
-
ROLE_LEVELS[name] || level
-
end
-
-
1
def level_name
-
case level_value
-
when LEVEL_ADMIN then "Admin"
-
when LEVEL_LEGAL then "Legal"
-
when LEVEL_HR then "HR"
-
when LEVEL_EMPLOYEE then "Employee"
-
when LEVEL_VIEWER then "Viewer"
-
else "Custom (#{level_value})"
-
end
-
end
-
-
1
def higher_level_than?(other_role)
-
level_value > other_role.level_value
-
end
-
-
1
def same_level_as?(other_role)
-
level_value == other_role.level_value
-
end
-
-
1
def lower_level_than?(other_role)
-
level_value < other_role.level_value
-
end
-
-
1
def at_least_level?(min_level)
-
level_value >= min_level
-
end
-
-
1
def at_most_level?(max_level)
-
level_value <= max_level
-
end
-
-
1
class << self
-
1
def level_for(role_name)
-
ROLE_LEVELS[role_name] || 0
-
end
-
-
1
def role_for_level(level)
-
LEVELS[level]
-
end
-
-
1
def roles_at_level(min_level)
-
ROLE_LEVELS.select { |_, v| v >= min_level }.keys
-
end
-
-
1
def seed_defaults!
-
default_roles.each do |attrs|
-
role = find_or_create_by!(name: attrs[:name]) do |r|
-
r.display_name = attrs[:display_name]
-
r.description = attrs[:description]
-
r.system_role = true
-
r.level = attrs[:level]
-
end
-
-
# Update level if role already exists (for migration)
-
if role.level != attrs[:level]
-
role.update!(level: attrs[:level])
-
end
-
-
# Assign permissions
-
assign_permissions(role, attrs[:permissions])
-
end
-
end
-
-
1
def find_by_name(name)
-
find_by(name: name)
-
end
-
-
1
def find_by_name!(name)
-
find_by!(name: name)
-
end
-
-
1
private
-
-
1
def assign_permissions(role, permission_names)
-
return if permission_names.blank?
-
-
permissions = Identity::Permission.where(:name.in => permission_names)
-
role.permissions = permissions
-
role.save!
-
end
-
-
1
def default_roles
-
[
-
{
-
name: ADMIN,
-
display_name: "Administrador",
-
description: "Acceso total al sistema (Nivel 5)",
-
level: LEVEL_ADMIN,
-
permissions: []
-
},
-
{
-
name: CEO,
-
display_name: "CEO",
-
description: "Director Ejecutivo - Máxima autoridad (Nivel 5)",
-
level: LEVEL_ADMIN,
-
permissions: []
-
},
-
{
-
name: GENERAL_MANAGER,
-
display_name: "Gerente General",
-
description: "Gerente general de la empresa (Nivel 5)",
-
level: LEVEL_ADMIN,
-
permissions: []
-
},
-
{
-
name: LEGAL_REPRESENTATIVE,
-
display_name: "Representante Legal",
-
description: "Representante legal de la empresa - Firma documentos oficiales (Nivel 5)",
-
level: LEVEL_ADMIN,
-
permissions: []
-
},
-
{
-
name: LEGAL,
-
display_name: "Legal",
-
description: "Departamento legal (Nivel 4)",
-
level: LEVEL_LEGAL,
-
permissions: [
-
"documents.read", "documents.create", "documents.update", "documents.export",
-
"legal_documents.read", "legal_documents.manage",
-
"audit_logs.read"
-
]
-
},
-
{
-
name: HR_MANAGER,
-
display_name: "Gerente de RR.HH.",
-
description: "Gerente de Recursos Humanos (Nivel 4)",
-
level: LEVEL_LEGAL,
-
permissions: [
-
"documents.read", "documents.create", "documents.update",
-
"hr_requests.read", "hr_requests.manage",
-
"users.read", "users.manage"
-
]
-
},
-
{
-
name: HR,
-
display_name: "Recursos Humanos",
-
description: "Personal de Recursos Humanos (Nivel 3)",
-
level: LEVEL_HR,
-
permissions: [
-
"documents.read", "documents.create", "documents.update",
-
"hr_requests.read", "hr_requests.manage",
-
"users.read"
-
]
-
},
-
{
-
name: ACCOUNTANT,
-
display_name: "Contador",
-
description: "Área contable y financiera (Nivel 3)",
-
level: LEVEL_HR,
-
permissions: [
-
"documents.read", "documents.create", "documents.update"
-
]
-
},
-
{
-
name: MANAGER,
-
display_name: "Jefe de Área",
-
description: "Jefe o supervisor de área (Nivel 3)",
-
level: LEVEL_HR,
-
permissions: [
-
"documents.read", "documents.create", "documents.update",
-
"hr_requests.read"
-
]
-
},
-
{
-
name: EMPLOYEE,
-
display_name: "Empleado",
-
description: "Empleado estándar (Nivel 2)",
-
level: LEVEL_EMPLOYEE,
-
permissions: [
-
"documents.read", "documents.create", "documents.update",
-
"hr_requests.read"
-
]
-
},
-
{
-
name: VIEWER,
-
display_name: "Visor",
-
description: "Solo lectura de documentos (Nivel 1)",
-
level: LEVEL_VIEWER,
-
permissions: [
-
"documents.read"
-
]
-
}
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Identity
-
1
class User
-
1
include Mongoid::Document
-
1
include Mongoid::Timestamps
-
1
include UuidIdentifiable
-
1
include SoftDeletable
-
1
include AuditTrackable
-
-
# Devise modules
-
1
devise :database_authenticatable, :registerable,
-
:recoverable, :rememberable, :validatable,
-
:trackable, :lockable,
-
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
-
-
# Devise-JWT compatibility for Mongoid (ActiveRecord compatibility)
-
1
def self.primary_key
-
"_id"
-
end
-
-
1
store_in collection: "users"
-
-
# Basic fields
-
1
field :email, type: String
-
1
field :first_name, type: String
-
1
field :last_name, type: String
-
1
field :employee_id, type: String
-
1
field :department, type: String
-
1
field :title, type: String
-
1
field :phone, type: String
-
1
field :time_zone, type: String, default: "UTC"
-
1
field :locale, type: String, default: "en"
-
1
field :active, type: Boolean, default: true
-
-
# Devise fields
-
1
field :encrypted_password, type: String, default: ""
-
1
field :reset_password_token, type: String
-
1
field :reset_password_sent_at, type: Time
-
1
field :remember_created_at, type: Time
-
-
# Trackable
-
1
field :sign_in_count, type: Integer, default: 0
-
1
field :current_sign_in_at, type: Time
-
1
field :last_sign_in_at, type: Time
-
1
field :current_sign_in_ip, type: String
-
1
field :last_sign_in_ip, type: String
-
-
# Lockable
-
1
field :failed_attempts, type: Integer, default: 0
-
1
field :unlock_token, type: String
-
1
field :locked_at, type: Time
-
-
# Password change required (for new users created from contracts)
-
1
field :must_change_password, type: Boolean, default: false
-
1
field :password_changed_at, type: Time
-
-
# Indexes
-
1
index({ email: 1 }, { unique: true })
-
1
index({ employee_id: 1 }, { sparse: true })
-
1
index({ reset_password_token: 1 }, { unique: true, sparse: true })
-
1
index({ unlock_token: 1 }, { unique: true, sparse: true })
-
1
index({ organization_id: 1 })
-
1
index({ active: 1 })
-
1
index({ last_name: 1, first_name: 1 })
-
-
# Associations
-
1
belongs_to :organization, class_name: "Identity::Organization", inverse_of: :users, optional: true
-
1
has_and_belongs_to_many :roles, class_name: "Identity::Role", inverse_of: :users
-
1
has_many :signatures, class_name: "Identity::UserSignature", inverse_of: :user, dependent: :destroy
-
-
# Validations
-
1
validates :email, presence: true, uniqueness: true
-
1
validates :first_name, presence: true, length: { maximum: 50 }
-
1
validates :last_name, presence: true, length: { maximum: 50 }
-
1
validates :employee_id, uniqueness: true, allow_blank: true
-
-
# Scopes
-
1
scope :enabled, -> { where(active: true) }
-
1
scope :disabled, -> { where(active: false) }
-
1
scope :admins, -> { where(:role_ids.in => [Identity::Role.where(name: "admin").first&.id].compact) }
-
-
# Callbacks
-
1
after_create :assign_default_role
-
-
# Instance methods
-
1
def full_name
-
6
"#{first_name} #{last_name}".strip
-
end
-
-
1
def initials
-
"#{first_name&.first}#{last_name&.first}".upcase
-
end
-
-
1
def admin?
-
4
roles.any?(&:admin?)
-
end
-
-
1
def super_admin?
-
admin? # For now, admin is super_admin
-
end
-
-
1
def has_role?(role_name)
-
6
roles.exists?(name: role_name)
-
end
-
-
1
def has_permission?(permission_name)
-
return true if admin?
-
-
roles.any? { |role| role.has_permission?(permission_name) }
-
end
-
-
1
def can?(action, resource)
-
return true if admin?
-
-
roles.any? { |role| role.can?(action, resource) }
-
end
-
-
1
def permission_names
-
return ["*"] if admin? # Admin has all permissions
-
-
roles.flat_map(&:permission_names).uniq
-
end
-
-
1
def role_names
-
roles.pluck(:name)
-
end
-
-
1
def highest_role
-
roles.by_level.first
-
end
-
-
# Permission level methods (1-5 scale)
-
1
def permission_level
-
highest_role&.level_value || 0
-
end
-
-
1
def level_name
-
highest_role&.level_name || "None"
-
end
-
-
1
def at_least_level?(min_level)
-
permission_level >= min_level
-
end
-
-
1
def at_most_level?(max_level)
-
permission_level <= max_level
-
end
-
-
1
def higher_level_than?(other_user)
-
permission_level > other_user.permission_level
-
end
-
-
1
def same_level_as?(other_user)
-
permission_level == other_user.permission_level
-
end
-
-
1
def lower_level_than?(other_user)
-
permission_level < other_user.permission_level
-
end
-
-
# Level-specific helpers using constants
-
1
def viewer?
-
has_role?(Identity::Role::VIEWER) || permission_level >= Identity::Role::LEVEL_VIEWER
-
end
-
-
1
def employee?
-
has_role?(Identity::Role::EMPLOYEE) || permission_level >= Identity::Role::LEVEL_EMPLOYEE
-
end
-
-
1
def hr?
-
has_role?(Identity::Role::HR) || permission_level >= Identity::Role::LEVEL_HR
-
end
-
-
1
def legal?
-
has_role?(Identity::Role::LEGAL) || permission_level >= Identity::Role::LEVEL_LEGAL
-
end
-
-
1
def activate!
-
update!(active: true)
-
end
-
-
1
def deactivate!
-
update!(active: false)
-
end
-
-
1
def assign_role!(role_name)
-
role = Identity::Role.find_by!(name: role_name)
-
roles << role unless roles.include?(role)
-
end
-
-
1
def remove_role!(role_name)
-
role = Identity::Role.find_by!(name: role_name)
-
roles.delete(role)
-
end
-
-
1
def default_signature
-
signatures.default_signature.first || signatures.first
-
end
-
-
1
def has_signature?
-
signatures.any?
-
end
-
-
# JWT payload customization
-
1
def jwt_payload
-
{
-
"user_id" => id.to_s,
-
"email" => email,
-
"roles" => role_names,
-
"permission_level" => permission_level,
-
"organization_id" => organization_id&.to_s,
-
"must_change_password" => must_change_password
-
}
-
end
-
-
# Mark password as changed
-
1
def password_changed!
-
update!(must_change_password: false, password_changed_at: Time.current)
-
end
-
-
1
private
-
-
1
def assign_default_role
-
18
return if roles.any?
-
-
18
default_role = Identity::Role.where(name: Identity::Role::EMPLOYEE).first
-
18
roles << default_role if default_role
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Identity
-
class UserSignature
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "user_signatures"
-
-
# Signature types
-
DRAWN = "drawn"
-
STYLED = "styled"
-
SIGNATURE_TYPES = [DRAWN, STYLED].freeze
-
-
# Available fonts for styled signatures
-
SIGNATURE_FONTS = [
-
"Allura",
-
"Dancing Script",
-
"Great Vibes",
-
"Pacifico",
-
"Sacramento"
-
].freeze
-
-
# Fields
-
field :name, type: String # Name for this signature (e.g., "Formal", "Initials")
-
field :signature_type, type: String # drawn or styled
-
field :is_default, type: Boolean, default: false
-
field :active, type: Boolean, default: true # Can be disabled instead of deleted if in use
-
-
# For drawn signatures - stored as base64 PNG
-
field :image_data, type: String # Base64 encoded PNG image
-
-
# For styled signatures
-
field :styled_text, type: String # The text to display
-
field :font_family, type: String # Font name from SIGNATURE_FONTS
-
field :font_color, type: String, default: "#000000"
-
field :font_size, type: Integer, default: 48
-
-
# Associations
-
belongs_to :user, class_name: "Identity::User", inverse_of: :signatures
-
-
# Indexes
-
index({ user_id: 1 })
-
index({ user_id: 1, is_default: 1 })
-
index({ signature_type: 1 })
-
-
# Validations
-
validates :name, presence: true, length: { maximum: 100 }
-
validates :signature_type, presence: true, inclusion: { in: SIGNATURE_TYPES }
-
validates :image_data, presence: true, if: -> { signature_type == DRAWN }
-
validates :styled_text, presence: true, if: -> { signature_type == STYLED }
-
validates :font_family, presence: true, inclusion: { in: SIGNATURE_FONTS }, if: -> { signature_type == STYLED }
-
-
validate :only_one_default_per_user
-
-
# Scopes
-
scope :drawn, -> { where(signature_type: DRAWN) }
-
scope :styled, -> { where(signature_type: STYLED) }
-
scope :default_signature, -> { where(is_default: true) }
-
scope :active, -> { where(active: true) }
-
scope :inactive, -> { where(active: false) }
-
-
# Callbacks
-
before_save :ensure_single_default
-
before_destroy :prevent_destroy_if_in_use
-
after_destroy :ensure_default_exists
-
-
# Instance methods
-
def drawn?
-
signature_type == DRAWN
-
end
-
-
def styled?
-
signature_type == STYLED
-
end
-
-
def set_as_default!
-
user.signatures.update_all(is_default: false)
-
update!(is_default: true)
-
end
-
-
# Check if this signature is used in any generated document
-
def in_use?
-
documents_using_count > 0
-
end
-
-
# Count documents using this signature
-
def documents_using_count
-
::Templates::GeneratedDocument.where("signatures.signature_id" => uuid).count
-
end
-
-
# Get documents using this signature
-
def documents_using
-
::Templates::GeneratedDocument.where("signatures.signature_id" => uuid)
-
end
-
-
# Disable signature (soft delete alternative)
-
def disable!
-
update!(active: false, is_default: false)
-
end
-
-
# Enable signature
-
def enable!
-
update!(active: true)
-
end
-
-
def active?
-
active == true
-
end
-
-
def inactive?
-
!active?
-
end
-
-
# Returns the signature as a renderable format for PDF
-
def to_image_data
-
if drawn?
-
# Return the base64 PNG data directly
-
image_data
-
else
-
# For styled signatures, we'll render server-side using MiniMagick
-
render_styled_signature
-
end
-
end
-
-
# Render styled signature as base64 PNG image
-
def render_styled_signature
-
return image_data if image_data.present? && styled?
-
-
Templates::SignatureRendererService.new(self).render_styled
-
end
-
-
private
-
-
def only_one_default_per_user
-
return unless is_default && is_default_changed?
-
-
if user&.signatures&.where(is_default: true)&.where(:id.ne => id)&.exists?
-
# This is fine, we'll update in before_save callback
-
end
-
end
-
-
def ensure_single_default
-
return unless is_default && is_default_changed?
-
-
user.signatures.where(:id.ne => id).update_all(is_default: false)
-
end
-
-
def ensure_default_exists
-
return unless is_default
-
return unless user.signatures.any?
-
-
# Set the first remaining signature as default
-
user.signatures.first.update!(is_default: true)
-
end
-
-
def prevent_destroy_if_in_use
-
return unless in_use?
-
-
errors.add(:base, "No se puede eliminar una firma que está siendo utilizada en documentos. Desactívela en su lugar.")
-
throw(:abort)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Legal
-
class Contract
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
# Constants
-
TYPES = %w[
-
services purchase nda lease partnership
-
employment consulting maintenance license other
-
].freeze
-
-
TYPE_LABELS = {
-
"services" => "Prestación de Servicios",
-
"purchase" => "Compraventa",
-
"nda" => "Confidencialidad (NDA)",
-
"lease" => "Arrendamiento",
-
"partnership" => "Alianza/Asociación",
-
"employment" => "Laboral",
-
"consulting" => "Consultoría",
-
"maintenance" => "Mantenimiento",
-
"license" => "Licencia",
-
"other" => "Otro"
-
}.freeze
-
-
STATUSES = %w[
-
draft pending_approval approved pending_signatures
-
active expired terminated cancelled rejected archived
-
].freeze
-
-
STATUS_LABELS = {
-
"draft" => "Borrador",
-
"pending_approval" => "Pendiente de Aprobación",
-
"approved" => "Aprobado",
-
"pending_signatures" => "Pendiente de Firmas",
-
"rejected" => "Rechazado",
-
"active" => "Activo",
-
"expired" => "Vencido",
-
"terminated" => "Terminado",
-
"cancelled" => "Cancelado",
-
"archived" => "Archivado"
-
}.freeze
-
-
CURRENCIES = %w[COP USD EUR].freeze
-
-
# Approval levels based on amount (in COP)
-
APPROVAL_LEVELS = {
-
"level_1" => { max_amount: 10_000_000, approvers: %w[area_manager], label: "Nivel 1 (≤$10M)" },
-
"level_2" => { max_amount: 50_000_000, approvers: %w[area_manager legal], label: "Nivel 2 (≤$50M)" },
-
"level_3" => { max_amount: 200_000_000, approvers: %w[area_manager legal general_manager], label: "Nivel 3 (≤$200M)" },
-
"level_4" => { max_amount: Float::INFINITY, approvers: %w[area_manager legal general_manager ceo], label: "Nivel 4 (>$200M)" }
-
}.freeze
-
-
# Collection
-
store_in collection: "legal_contracts"
-
-
# Fields - Basic info
-
field :contract_number, type: String
-
field :title, type: String
-
field :description, type: String
-
field :contract_type, type: String, default: "services"
-
field :status, type: String, default: "draft"
-
-
# Dates
-
field :start_date, type: Date
-
field :end_date, type: Date
-
field :signature_date, type: Date
-
-
# Financial
-
field :amount, type: BigDecimal
-
field :currency, type: String, default: "COP"
-
field :payment_terms, type: String
-
field :payment_frequency, type: String # monthly, quarterly, annually, one_time
-
-
# Approval workflow
-
field :approval_level, type: String
-
field :current_approver_role, type: String
-
field :submitted_at, type: Time
-
field :approved_at, type: Time
-
field :rejected_at, type: Time
-
field :rejection_reason, type: String
-
-
# Document
-
field :document_uuid, type: String
-
field :template_id, type: String
-
field :attachments, type: Array, default: [] # GridFS IDs
-
-
# Renewal
-
field :auto_renewal, type: Boolean, default: false
-
field :renewal_notice_days, type: Integer, default: 30
-
field :renewal_terms, type: String
-
-
# History/Audit
-
field :history, type: Array, default: []
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :third_party, class_name: "Legal::ThirdParty"
-
belongs_to :requested_by, class_name: "Identity::User"
-
belongs_to :area_manager, class_name: "Identity::User", optional: true
-
-
embeds_many :approvals, class_name: "Legal::ContractApproval"
-
-
# Validations
-
validates :contract_type, presence: true, inclusion: { in: TYPES }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validates :currency, inclusion: { in: CURRENCIES }
-
validates :title, presence: true
-
validates :amount, presence: true, numericality: { greater_than: 0 }
-
validates :start_date, presence: true
-
validates :end_date, presence: true
-
validate :end_date_after_start_date
-
-
# Indexes
-
index({ organization_id: 1, status: 1 })
-
index({ organization_id: 1, contract_type: 1 })
-
index({ contract_number: 1 }, unique: true)
-
index({ third_party_id: 1 })
-
index({ requested_by_id: 1 })
-
index({ end_date: 1 })
-
-
# Callbacks
-
before_create :generate_contract_number
-
before_save :determine_approval_level, if: :amount_changed?
-
-
# Scopes
-
scope :draft, -> { where(status: "draft") }
-
scope :pending_approval, -> { where(status: "pending_approval") }
-
scope :approved, -> { where(status: "approved") }
-
scope :pending_signatures, -> { where(status: "pending_signatures") }
-
scope :rejected, -> { where(status: "rejected") }
-
scope :active, -> { where(status: "active") }
-
scope :expired, -> { where(status: "expired") }
-
scope :archived, -> { where(status: "archived") }
-
scope :not_archived, -> { where.not(status: "archived") }
-
scope :by_type, ->(type) { where(contract_type: type) }
-
scope :by_third_party, ->(tp_id) { where(third_party_id: tp_id) }
-
scope :expiring_soon, ->(days = 30) { active.where(:end_date.lte => Date.current + days) }
-
scope :search, ->(query) {
-
return all if query.blank?
-
regex = /#{Regexp.escape(query)}/i
-
any_of(
-
{ title: regex },
-
{ contract_number: regex },
-
{ description: regex }
-
)
-
}
-
-
# State predicates
-
def draft?; status == "draft"; end
-
def pending_approval?; status == "pending_approval"; end
-
def approved?; status == "approved"; end
-
def pending_signatures?; status == "pending_signatures"; end
-
def rejected?; status == "rejected"; end
-
def active?; status == "active"; end
-
def expired?; status == "expired"; end
-
def terminated?; status == "terminated"; end
-
def cancelled?; status == "cancelled"; end
-
def archived?; status == "archived"; end
-
-
def editable?
-
draft?
-
end
-
-
def can_submit?
-
draft? && valid?
-
end
-
-
def can_activate?
-
approved? || (pending_signatures? && document_all_signed?)
-
end
-
-
# Document and signature helpers
-
def generated_document
-
return nil unless document_uuid
-
@generated_document ||= ::Templates::GeneratedDocument.find_by(uuid: document_uuid)
-
end
-
-
def document_has_pending_signatures?
-
doc = generated_document
-
return false unless doc
-
doc.pending_signatures? && doc.pending_signatures_count > 0
-
end
-
-
def document_all_signed?
-
doc = generated_document
-
return true unless doc # No document = nothing to sign
-
return true unless doc.signatures.any? # No signatures configured
-
doc.all_required_signed?
-
end
-
-
def document_pending_signatures_count
-
generated_document&.pending_signatures_count || 0
-
end
-
-
def document_signatures_status
-
doc = generated_document
-
return nil unless doc
-
-
{
-
total: doc.total_required_signatures,
-
signed: doc.completed_signatures_count,
-
pending: doc.pending_signatures_count,
-
all_signed: doc.all_required_signed?
-
}
-
end
-
-
# Workflow methods
-
def submit!(actor:)
-
raise InvalidStateError, "Solo se pueden enviar contratos en borrador" unless draft?
-
raise ValidationError, "El contrato no es válido" unless valid?
-
-
determine_approval_level
-
initialize_approvals!
-
-
self.status = "pending_approval"
-
self.submitted_at = Time.current
-
self.current_approver_role = required_approvers.first
-
-
record_history("submitted", actor, { approval_level: approval_level })
-
save!
-
end
-
-
def approve!(actor:, role:, notes: nil)
-
raise InvalidStateError, "Solo se pueden aprobar contratos pendientes" unless pending_approval?
-
-
approval = approvals.find { |a| a.role == role && a.pending? }
-
raise AuthorizationError, "No hay aprobación pendiente para el rol #{role}" unless approval
-
raise AuthorizationError, "No es tu turno de aprobar" unless current_approver_role == role
-
raise AuthorizationError, "No tienes permisos para aprobar como #{role}" unless approval.can_be_decided_by?(actor)
-
-
approval.approve!(actor: actor, notes: notes)
-
record_history("approved_by", actor, { role: role, notes: notes })
-
-
next_role = next_approver_role
-
if next_role
-
self.current_approver_role = next_role
-
else
-
# All approvals complete - check if document requires signatures
-
if document_has_pending_signatures?
-
self.status = "pending_signatures"
-
record_history("pending_signatures", actor, { message: "Esperando firmas del documento" })
-
else
-
self.status = "approved"
-
end
-
self.approved_at = Time.current
-
self.current_approver_role = nil
-
end
-
-
save!
-
end
-
-
def reject!(actor:, role:, reason:)
-
raise InvalidStateError, "Solo se pueden rechazar contratos pendientes" unless pending_approval?
-
raise ArgumentError, "Se requiere un motivo de rechazo" if reason.blank?
-
-
approval = approvals.find { |a| a.role == role && a.pending? }
-
raise AuthorizationError, "No hay aprobación pendiente para el rol #{role}" unless approval
-
raise AuthorizationError, "No tienes permisos para rechazar como #{role}" unless approval.can_be_decided_by?(actor)
-
-
approval.reject!(actor: actor, reason: reason)
-
-
self.status = "rejected"
-
self.rejected_at = Time.current
-
self.rejection_reason = reason
-
self.current_approver_role = nil
-
-
record_history("rejected", actor, { role: role, reason: reason })
-
save!
-
end
-
-
def activate!(actor: nil)
-
raise InvalidStateError, "Solo se pueden activar contratos aprobados" unless can_activate?
-
-
if pending_signatures? && !document_all_signed?
-
raise InvalidStateError, "El documento tiene firmas pendientes"
-
end
-
-
self.status = "active"
-
record_history("activated", actor) if actor
-
save!
-
end
-
-
def terminate!(actor:, reason: nil)
-
raise InvalidStateError, "Solo se pueden terminar contratos activos" unless active?
-
-
self.status = "terminated"
-
record_history("terminated", actor, { reason: reason })
-
save!
-
end
-
-
def cancel!(actor:, reason: nil)
-
raise InvalidStateError, "No se puede cancelar este contrato" if active? || expired? || terminated?
-
-
self.status = "cancelled"
-
record_history("cancelled", actor, { reason: reason })
-
save!
-
end
-
-
def expire!
-
return unless active? && end_date && end_date < Date.current
-
-
self.status = "expired"
-
record_history("expired", nil, { expired_on: Date.current })
-
save!
-
end
-
-
def archive!(actor:)
-
# Can archive completed contracts (active, expired, terminated, cancelled)
-
archivable_statuses = %w[active expired terminated cancelled]
-
raise InvalidStateError, "Solo se pueden archivar contratos completados" unless archivable_statuses.include?(status)
-
-
self.status = "archived"
-
record_history("archived", actor, { archived_at: Time.current })
-
save!
-
end
-
-
def unarchive!(actor:)
-
raise InvalidStateError, "El contrato no está archivado" unless archived?
-
-
# Restore to expired status (safest default for archived contracts)
-
self.status = "expired"
-
record_history("unarchived", actor, { unarchived_at: Time.current })
-
save!
-
end
-
-
def complete_signatures!(actor:)
-
raise InvalidStateError, "Solo aplica para contratos pendientes de firmas" unless pending_signatures?
-
raise InvalidStateError, "El documento tiene firmas pendientes" unless document_all_signed?
-
-
self.status = "approved"
-
record_history("signatures_completed", actor, { message: "Todas las firmas han sido completadas" })
-
save!
-
end
-
-
# Approval helpers
-
def required_approvers
-
return [] unless approval_level
-
APPROVAL_LEVELS.dig(approval_level, :approvers) || []
-
end
-
-
def next_approver_role
-
approved_roles = approvals.select(&:approved?).map(&:role)
-
required_approvers.find { |r| !approved_roles.include?(r) }
-
end
-
-
def approval_progress
-
return 0 if approvals.empty?
-
(approvals.count(&:approved?).to_f / approvals.count * 100).round
-
end
-
-
def can_approve?(user)
-
return false unless pending_approval?
-
return false unless current_approver_role
-
-
approval = approvals.find { |a| a.role == current_approver_role && a.pending? }
-
approval&.can_be_decided_by?(user) || false
-
end
-
-
def pending_approval_for_user?(user)
-
can_approve?(user)
-
end
-
-
# Labels
-
def type_label
-
TYPE_LABELS[contract_type] || contract_type.humanize
-
end
-
-
def status_label
-
STATUS_LABELS[status] || status.humanize
-
end
-
-
def approval_level_label
-
APPROVAL_LEVELS.dig(approval_level, :label) || approval_level
-
end
-
-
def current_approver_label
-
return nil unless current_approver_role
-
ContractApproval::ROLE_LABELS[current_approver_role] || current_approver_role.humanize
-
end
-
-
# Duration
-
def duration_days
-
return nil unless start_date && end_date
-
(end_date - start_date).to_i
-
end
-
-
def days_until_expiry
-
return nil unless end_date
-
(end_date - Date.current).to_i
-
end
-
-
def expiring_soon?(days = 30)
-
active? && days_until_expiry && days_until_expiry <= days
-
end
-
-
# Errors
-
class InvalidStateError < StandardError; end
-
class AuthorizationError < StandardError; end
-
class ValidationError < StandardError; end
-
-
private
-
-
def generate_contract_number
-
return if contract_number.present?
-
-
year = Time.current.year
-
last_record = self.class
-
.where(organization_id: organization_id)
-
.where(:contract_number.ne => nil)
-
.order(created_at: :desc)
-
.first
-
-
if last_record&.contract_number&.match?(/CON-#{year}-(\d+)/)
-
last_num = last_record.contract_number.split("-").last.to_i
-
self.contract_number = "CON-#{year}-#{(last_num + 1).to_s.rjust(5, '0')}"
-
else
-
self.contract_number = "CON-#{year}-00001"
-
end
-
end
-
-
def determine_approval_level
-
return unless amount
-
-
amount_cop = amount.to_f
-
level = APPROVAL_LEVELS.find { |_, config| amount_cop <= config[:max_amount] }
-
self.approval_level = level&.first || "level_4"
-
end
-
-
def initialize_approvals!
-
self.approvals = []
-
required_approvers.each_with_index do |role, index|
-
approvals.build(role: role, status: "pending", order: index)
-
end
-
end
-
-
def record_history(action, actor, details = {})
-
history << {
-
action: action,
-
actor_id: actor&.id&.to_s,
-
actor_name: actor&.full_name,
-
timestamp: Time.current.iso8601,
-
details: details
-
}
-
end
-
-
def end_date_after_start_date
-
return unless start_date && end_date
-
errors.add(:end_date, "debe ser posterior a la fecha de inicio") if end_date < start_date
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Legal
-
class ContractApproval
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
-
# Constants
-
STATUSES = %w[pending approved rejected].freeze
-
ROLES = %w[area_manager legal general_manager ceo].freeze
-
-
ROLE_LABELS = {
-
"area_manager" => "Jefe de Área",
-
"legal" => "Legal",
-
"general_manager" => "Gerente General",
-
"ceo" => "CEO"
-
}.freeze
-
-
# Fields
-
field :role, type: String
-
field :status, type: String, default: "pending"
-
field :order, type: Integer, default: 0
-
field :decided_at, type: Time
-
field :notes, type: String
-
field :reason, type: String # For rejections
-
-
# Approver info (captured at decision time)
-
field :approver_id, type: String
-
field :approver_name, type: String
-
field :approver_email, type: String
-
-
# Embedded in Contract
-
embedded_in :contract, class_name: "Legal::Contract"
-
-
# Validations
-
validates :role, presence: true, inclusion: { in: ROLES }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
-
# Instance methods
-
def pending?
-
status == "pending"
-
end
-
-
def approved?
-
status == "approved"
-
end
-
-
def rejected?
-
status == "rejected"
-
end
-
-
def decided?
-
approved? || rejected?
-
end
-
-
def role_label
-
ROLE_LABELS[role] || role.humanize
-
end
-
-
def approve!(actor:, notes: nil)
-
self.status = "approved"
-
self.decided_at = Time.current
-
self.approver_id = actor.id.to_s
-
self.approver_name = actor.full_name
-
self.approver_email = actor.email
-
self.notes = notes
-
end
-
-
def reject!(actor:, reason:)
-
self.status = "rejected"
-
self.decided_at = Time.current
-
self.approver_id = actor.id.to_s
-
self.approver_name = actor.full_name
-
self.approver_email = actor.email
-
self.reason = reason
-
end
-
-
def can_be_decided_by?(user)
-
pending? && user_has_approval_role?(user)
-
end
-
-
private
-
-
def user_has_approval_role?(user)
-
case role
-
when "area_manager"
-
user.has_role?("manager") || user.has_role?("admin")
-
when "legal"
-
user.has_role?("legal") || user.has_role?("admin")
-
when "general_manager"
-
user.has_role?("general_manager") || user.has_role?("admin")
-
when "ceo"
-
user.has_role?("ceo") || user.has_role?("admin")
-
else
-
false
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Legal
-
class ThirdParty
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
# Constants
-
TYPES = %w[provider client contractor partner other].freeze
-
PERSON_TYPES = %w[natural juridical].freeze
-
STATUSES = %w[active inactive blocked].freeze
-
IDENTIFICATION_TYPES = %w[NIT CC CE PA TI NIP].freeze
-
-
# Collection
-
store_in collection: "legal_third_parties"
-
-
# Fields - Identification
-
field :code, type: String
-
field :third_party_type, type: String, default: "provider"
-
field :person_type, type: String, default: "juridical"
-
field :status, type: String, default: "active"
-
-
# Identification documents
-
field :identification_type, type: String, default: "NIT"
-
field :identification_number, type: String
-
field :verification_digit, type: String # For NIT
-
-
# Business info (juridical)
-
field :business_name, type: String
-
field :trade_name, type: String
-
-
# Personal info (natural)
-
field :first_name, type: String
-
field :last_name, type: String
-
-
# Contact
-
field :email, type: String
-
field :phone, type: String
-
field :mobile, type: String
-
field :website, type: String
-
-
# Address
-
field :address, type: String
-
field :city, type: String
-
field :state, type: String
-
field :postal_code, type: String
-
field :country, type: String, default: "Colombia"
-
-
# Legal representative (for juridical)
-
field :legal_rep_name, type: String
-
field :legal_rep_id_type, type: String
-
field :legal_rep_id_number, type: String
-
field :legal_rep_id_city, type: String
-
field :legal_rep_email, type: String
-
field :legal_rep_phone, type: String
-
-
# Banking info
-
field :bank_name, type: String
-
field :bank_account_type, type: String # savings, checking
-
field :bank_account_number, type: String
-
-
# Categorization
-
field :industry, type: String
-
field :tags, type: Array, default: []
-
field :notes, type: String
-
-
# Tax info
-
field :tax_regime, type: String # simplified, common, special
-
field :tax_responsibilities, type: Array, default: []
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :created_by, class_name: "Identity::User", optional: true
-
has_many :contracts, class_name: "Legal::Contract", dependent: :restrict_with_error
-
-
# Validations
-
validates :third_party_type, presence: true
-
validate :valid_third_party_type
-
validates :person_type, presence: true, inclusion: { in: PERSON_TYPES }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validates :identification_type, inclusion: { in: IDENTIFICATION_TYPES }, allow_blank: true
-
validates :identification_number, presence: true, uniqueness: { scope: :organization_id }
-
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
-
-
# Conditional validations
-
validates :business_name, presence: true, if: :juridical?
-
validates :first_name, :last_name, presence: true, if: :natural?
-
-
# Indexes
-
index({ organization_id: 1, status: 1 })
-
index({ organization_id: 1, third_party_type: 1 })
-
index({ identification_number: 1, organization_id: 1 }, unique: true)
-
index({ code: 1 }, unique: true)
-
index({ email: 1 })
-
-
# Callbacks
-
before_create :generate_code
-
-
# Scopes
-
scope :active, -> { where(status: "active") }
-
scope :inactive, -> { where(status: "inactive") }
-
scope :blocked, -> { where(status: "blocked") }
-
scope :providers, -> { where(third_party_type: "provider") }
-
scope :clients, -> { where(third_party_type: "client") }
-
scope :contractors, -> { where(third_party_type: "contractor") }
-
scope :partners, -> { where(third_party_type: "partner") }
-
scope :by_type, ->(type) { where(third_party_type: type) }
-
scope :search, ->(query) {
-
return all if query.blank?
-
regex = /#{Regexp.escape(query)}/i
-
any_of(
-
{ business_name: regex },
-
{ trade_name: regex },
-
{ first_name: regex },
-
{ last_name: regex },
-
{ identification_number: regex },
-
{ email: regex },
-
{ code: regex }
-
)
-
}
-
-
# Custom validations
-
def valid_third_party_type
-
return if third_party_type.blank?
-
-
# Check if it's a default type
-
return if TYPES.include?(third_party_type)
-
-
# Check if it's a custom type from ThirdPartyType model
-
return if organization_id && ThirdPartyType.where(
-
organization_id: organization_id,
-
code: third_party_type,
-
active: true
-
).exists?
-
-
errors.add(:third_party_type, "is not a valid type")
-
end
-
-
# Instance methods
-
def display_name
-
if juridical?
-
trade_name.presence || business_name
-
else
-
"#{first_name} #{last_name}".strip
-
end
-
end
-
-
def full_name
-
display_name
-
end
-
-
def juridical?
-
person_type == "juridical"
-
end
-
-
def natural?
-
person_type == "natural"
-
end
-
-
def active?
-
status == "active"
-
end
-
-
def inactive?
-
status == "inactive"
-
end
-
-
def blocked?
-
status == "blocked"
-
end
-
-
def full_identification
-
"#{identification_type} #{identification_number}#{verification_digit.present? ? "-#{verification_digit}" : ""}"
-
end
-
-
def full_address
-
[address, city, state, country].compact.join(", ")
-
end
-
-
def type_label
-
I18n.t("legal.third_party.types.#{third_party_type}", default: third_party_type.humanize)
-
end
-
-
def status_label
-
I18n.t("legal.third_party.statuses.#{status}", default: status.humanize)
-
end
-
-
def activate!
-
update!(status: "active")
-
end
-
-
def deactivate!
-
update!(status: "inactive")
-
end
-
-
def block!(reason: nil)
-
update!(status: "blocked", notes: [notes, "Bloqueado: #{reason}"].compact.join("\n"))
-
end
-
-
private
-
-
def generate_code
-
return if code.present?
-
-
year = Time.current.year
-
last_record = self.class
-
.where(organization_id: organization_id)
-
.where(:code.ne => nil)
-
.order(created_at: :desc)
-
.first
-
-
if last_record&.code&.match?(/TER-#{year}-(\d+)/)
-
last_num = last_record.code.split("-").last.to_i
-
self.code = "TER-#{year}-#{(last_num + 1).to_s.rjust(5, '0')}"
-
else
-
self.code = "TER-#{year}-00001"
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Legal
-
class ThirdPartyType
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
# Collection
-
store_in collection: "legal_third_party_types"
-
-
# Fields
-
field :code, type: String # provider, client, etc.
-
field :name, type: String # Display name
-
field :description, type: String
-
field :color, type: String, default: "gray" # For UI badges
-
field :icon, type: String, default: "building"
-
field :active, type: Boolean, default: true
-
field :is_system, type: Boolean, default: false # System types cannot be deleted
-
field :position, type: Integer, default: 0
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization"
-
-
# Validations
-
validates :code, presence: true, uniqueness: { scope: :organization_id }
-
validates :name, presence: true
-
-
# Indexes
-
index({ organization_id: 1, active: 1 })
-
index({ organization_id: 1, code: 1 }, unique: true)
-
index({ position: 1 })
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :ordered, -> { order(position: :asc, name: :asc) }
-
-
# Class methods
-
def self.seed_defaults(organization)
-
defaults = [
-
{ code: "provider", name: "Proveedor", description: "Proveedores de bienes y servicios", color: "blue", icon: "truck", position: 1 },
-
{ code: "client", name: "Cliente", description: "Clientes de la organización", color: "green", icon: "users", position: 2 },
-
{ code: "contractor", name: "Contratista", description: "Contratistas independientes", color: "purple", icon: "briefcase", position: 3 },
-
{ code: "partner", name: "Aliado", description: "Aliados estratégicos", color: "orange", icon: "handshake", position: 4 },
-
{ code: "other", name: "Otro", description: "Otros tipos de terceros", color: "gray", icon: "building", position: 5 }
-
]
-
-
defaults.each do |attrs|
-
existing = where(organization_id: organization.id, code: attrs[:code]).first
-
if existing
-
existing.update(attrs.except(:code).merge(is_system: true))
-
else
-
create!(attrs.merge(organization_id: organization.id, is_system: true))
-
end
-
end
-
end
-
-
# Instance methods
-
def deletable?
-
!is_system && Legal::ThirdParty.where(organization_id: organization_id, third_party_type: code).count.zero?
-
end
-
-
def toggle_active!
-
update!(active: !active)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Retention
-
# Represents a legal hold placed on a document
-
# While under legal hold, a document cannot be modified, archived, or deleted
-
#
-
# Legal holds are typically placed during litigation, regulatory investigation,
-
# or audit situations where document preservation is legally required
-
#
-
# rubocop:disable Metrics/ClassLength
-
class LegalHold
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "legal_holds"
-
-
# Status constants
-
STATUS_ACTIVE = "active"
-
STATUS_RELEASED = "released"
-
-
STATUSES = [STATUS_ACTIVE, STATUS_RELEASED].freeze
-
-
# Hold types
-
TYPE_LITIGATION = "litigation"
-
TYPE_REGULATORY = "regulatory"
-
TYPE_AUDIT = "audit"
-
TYPE_INVESTIGATION = "investigation"
-
TYPE_PRESERVATION = "preservation"
-
-
TYPES = [TYPE_LITIGATION, TYPE_REGULATORY, TYPE_AUDIT, TYPE_INVESTIGATION, TYPE_PRESERVATION].freeze
-
-
# Fields
-
field :name, type: String
-
field :description, type: String
-
field :hold_type, type: String
-
field :status, type: String, default: STATUS_ACTIVE
-
field :reference_number, type: String # Case number, audit ID, etc.
-
-
# Dates
-
field :effective_date, type: Time
-
field :release_date, type: Time
-
field :expected_release_date, type: Time
-
-
# Release information
-
field :release_reason, type: String
-
field :released_by_id, type: BSON::ObjectId
-
field :released_by_name, type: String
-
-
# Custodian information
-
field :custodian_name, type: String
-
field :custodian_email, type: String
-
field :custodian_department, type: String
-
-
# Notes and history
-
field :notes, type: String
-
field :history, type: Array, default: []
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ status: 1 })
-
index({ reference_number: 1 })
-
index({ schedule_id: 1, status: 1 })
-
index({ organization_id: 1, status: 1 })
-
index({ hold_type: 1 })
-
-
# Associations
-
belongs_to :schedule, class_name: "Retention::RetentionSchedule", inverse_of: :legal_holds
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :placed_by, class_name: "Identity::User"
-
-
# Validations
-
validates :name, presence: true
-
validates :hold_type, presence: true, inclusion: { in: TYPES }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validates :effective_date, presence: true
-
validates :custodian_name, presence: true
-
-
# Scopes
-
scope :active, -> { where(status: STATUS_ACTIVE) }
-
scope :released, -> { where(status: STATUS_RELEASED) }
-
scope :by_type, ->(type) { where(hold_type: type) }
-
scope :for_reference, ->(ref) { where(reference_number: ref) }
-
-
# Callbacks
-
after_create :place_schedule_on_hold
-
after_save :update_schedule_hold_status, if: :saved_change_to_status?
-
-
# Check if hold is active
-
def active?
-
status == STATUS_ACTIVE
-
end
-
-
# Check if hold is released
-
def released?
-
status == STATUS_RELEASED
-
end
-
-
# Release the hold
-
# rubocop:disable Naming/PredicateMethod
-
def release!(actor:, reason:)
-
return false if released?
-
-
self.status = STATUS_RELEASED
-
self.release_date = Time.current
-
self.release_reason = reason
-
self.released_by_id = actor.id
-
self.released_by_name = actor.full_name
-
-
record_history("released", actor, reason)
-
save!
-
-
log_audit_event("legal_hold_released", actor, {
-
release_reason: reason,
-
hold_duration_days: hold_duration_days
-
})
-
-
true
-
end
-
# rubocop:enable Naming/PredicateMethod
-
-
# Extend expected release date
-
def extend!(new_expected_date:, actor:, reason: nil) # rubocop:disable Naming/PredicateMethod
-
return false unless active?
-
-
old_date = expected_release_date
-
self.expected_release_date = new_expected_date
-
-
record_history("extended", actor, "Extended to #{new_expected_date}. Reason: #{reason}")
-
save!
-
-
log_audit_event("legal_hold_extended", actor, {
-
old_expected_date: old_date&.iso8601,
-
new_expected_date: new_expected_date.iso8601,
-
reason: reason
-
})
-
-
true
-
end
-
-
# Duration of hold in days
-
def hold_duration_days
-
end_date = release_date || Time.current
-
((end_date - effective_date) / 1.day).ceil
-
end
-
-
# Get the document through schedule
-
def document
-
schedule&.document
-
end
-
-
# Human-readable hold type
-
def hold_type_display
-
hold_type.to_s.titleize
-
end
-
-
private
-
-
def place_schedule_on_hold
-
schedule.place_on_hold!(reason: "Legal hold: #{name}")
-
-
log_audit_event("legal_hold_placed", placed_by, {
-
hold_type: hold_type,
-
reference_number: reference_number,
-
custodian: custodian_name
-
})
-
end
-
-
def update_schedule_hold_status
-
return unless released?
-
-
# Check if there are other active holds
-
schedule.release_from_hold! unless schedule.legal_holds.active.exists?(:id.ne => id)
-
end
-
-
def record_history(action, actor, details = nil)
-
history << {
-
"action" => action,
-
"at" => Time.current.iso8601,
-
"actor_id" => actor&.id&.to_s,
-
"actor_name" => actor&.full_name,
-
"details" => details
-
}.compact
-
end
-
-
def log_audit_event(action, actor, metadata = {})
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:record],
-
action: action,
-
target: document || schedule,
-
actor: actor,
-
metadata: metadata.merge(
-
legal_hold_id: id.to_s,
-
legal_hold_name: name
-
),
-
tags: ["legal_hold", action]
-
)
-
end
-
-
class << self
-
# Find all holds for a document
-
def for_document(document)
-
schedule = Retention::RetentionSchedule.where(document_id: document.id).first
-
return none unless schedule
-
-
where(schedule_id: schedule.id)
-
end
-
-
# Place a new hold on a document
-
def place_hold!(document:, name:, hold_type:, placed_by:, organization:, **)
-
# Find or create retention schedule for document
-
schedule = Retention::RetentionSchedule.where(document_id: document.id).first
-
-
schedule ||= Retention::RetentionSchedule.create!(
-
document: document,
-
organization: organization,
-
status: Retention::RetentionSchedule::STATUS_HELD,
-
retention_start_date: Time.current
-
)
-
-
create!(
-
schedule: schedule,
-
organization: organization,
-
name: name,
-
hold_type: hold_type,
-
placed_by: placed_by,
-
effective_date: Time.current,
-
**
-
)
-
end
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
module Retention
-
# Defines retention rules for documents based on type
-
# Specifies how long documents should be retained before archiving/expiration
-
#
-
# Example: Contracts must be retained for 7 years after completion
-
#
-
class RetentionPolicy
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "retention_policies"
-
-
# Retention action types
-
ACTION_ARCHIVE = "archive"
-
ACTION_EXPIRE = "expire"
-
ACTION_REVIEW = "review"
-
ACTION_DESTROY = "destroy"
-
-
ACTIONS = [ACTION_ARCHIVE, ACTION_EXPIRE, ACTION_REVIEW, ACTION_DESTROY].freeze
-
-
# Trigger types - when to start counting retention period
-
TRIGGER_CREATION = "creation"
-
TRIGGER_LAST_MODIFIED = "last_modified"
-
TRIGGER_WORKFLOW_COMPLETE = "workflow_complete"
-
TRIGGER_CUSTOM_DATE = "custom_date"
-
-
TRIGGERS = [TRIGGER_CREATION, TRIGGER_LAST_MODIFIED, TRIGGER_WORKFLOW_COMPLETE, TRIGGER_CUSTOM_DATE].freeze
-
-
# Fields
-
field :name, type: String
-
field :description, type: String
-
field :document_type, type: String # Type of document this policy applies to
-
field :active, type: Boolean, default: true
-
-
# Retention period configuration
-
field :retention_period_days, type: Integer
-
field :retention_trigger, type: String, default: TRIGGER_CREATION
-
-
# Action to take when retention period expires
-
field :expiration_action, type: String, default: ACTION_ARCHIVE
-
-
# Warning period - days before expiration to send warnings
-
field :warning_days, type: Integer, default: 30
-
-
# Priority for policy selection (higher = more specific)
-
field :priority, type: Integer, default: 0
-
-
# Custom field for trigger date (if using custom_date trigger)
-
field :custom_trigger_field, type: String
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ document_type: 1, active: 1 })
-
index({ organization_id: 1, active: 1 })
-
index({ priority: -1 })
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization", optional: true
-
has_many :schedules, class_name: "Retention::RetentionSchedule", inverse_of: :policy
-
-
# Validations
-
validates :name, presence: true
-
validates :document_type, presence: true
-
validates :retention_period_days, presence: true, numericality: { greater_than: 0 }
-
validates :retention_trigger, presence: true, inclusion: { in: TRIGGERS }
-
validates :expiration_action, presence: true, inclusion: { in: ACTIONS }
-
validates :warning_days, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :for_document_type, ->(type) { where(document_type: type) }
-
scope :by_priority, -> { order(priority: :desc) }
-
scope :global, -> { where(organization_id: nil) }
-
-
# Calculate expiration date based on document
-
def calculate_expiration_date(document)
-
trigger_date = determine_trigger_date(document)
-
return nil unless trigger_date
-
-
trigger_date + retention_period_days.days
-
end
-
-
# Calculate warning date
-
def calculate_warning_date(document)
-
expiration = calculate_expiration_date(document)
-
return nil unless expiration && warning_days&.positive?
-
-
expiration - warning_days.days
-
end
-
-
# Human-readable retention period
-
def retention_period_text
-
if retention_period_days >= 365
-
years = retention_period_days / 365
-
"#{years} year#{"s" unless years == 1}"
-
elsif retention_period_days >= 30
-
months = retention_period_days / 30
-
"#{months} month#{"s" unless months == 1}"
-
else
-
"#{retention_period_days} day#{"s" unless retention_period_days == 1}"
-
end
-
end
-
-
private
-
-
def determine_trigger_date(document)
-
case retention_trigger
-
when TRIGGER_CREATION
-
document.created_at
-
when TRIGGER_LAST_MODIFIED
-
document.updated_at
-
when TRIGGER_WORKFLOW_COMPLETE
-
find_workflow_completion_date(document)
-
when TRIGGER_CUSTOM_DATE
-
document.metadata&.dig(custom_trigger_field)&.to_time
-
end
-
end
-
-
def find_workflow_completion_date(document)
-
# Find the most recent completed workflow for this document
-
workflow = Workflow::WorkflowInstance
-
.where(document_id: document.id, status: Workflow::WorkflowInstance::STATUS_COMPLETED)
-
.order(completed_at: :desc)
-
.first
-
-
workflow&.completed_at
-
end
-
-
class << self
-
# Find the best matching policy for a document
-
def find_policy_for(document, organization: nil)
-
# First try organization-specific policies
-
if organization
-
policy = active.for_document_type(document.document_type)
-
.where(organization_id: organization.id)
-
.by_priority
-
.first
-
return policy if policy
-
end
-
-
# Fall back to global policies
-
active.for_document_type(document.document_type)
-
.global
-
.by_priority
-
.first
-
end
-
-
# Seed default retention policies
-
# rubocop:disable Metrics/MethodLength
-
def seed_defaults!
-
policies = [
-
{
-
name: "Contract Retention",
-
description: "Contracts must be retained for 7 years after workflow completion",
-
document_type: "contract",
-
retention_period_days: 7 * 365, # 7 years
-
retention_trigger: TRIGGER_WORKFLOW_COMPLETE,
-
expiration_action: ACTION_ARCHIVE,
-
warning_days: 90,
-
priority: 10
-
},
-
{
-
name: "Invoice Retention",
-
description: "Invoices must be retained for 5 years from creation",
-
document_type: "invoice",
-
retention_period_days: 5 * 365, # 5 years
-
retention_trigger: TRIGGER_CREATION,
-
expiration_action: ACTION_ARCHIVE,
-
warning_days: 60,
-
priority: 10
-
},
-
{
-
name: "HR Document Retention",
-
description: "HR documents retained for 7 years after last modification",
-
document_type: "hr_document",
-
retention_period_days: 7 * 365,
-
retention_trigger: TRIGGER_LAST_MODIFIED,
-
expiration_action: ACTION_REVIEW,
-
warning_days: 90,
-
priority: 10
-
},
-
{
-
name: "General Document Retention",
-
description: "Default retention policy for general documents",
-
document_type: "general",
-
retention_period_days: 3 * 365, # 3 years
-
retention_trigger: TRIGGER_CREATION,
-
expiration_action: ACTION_ARCHIVE,
-
warning_days: 30,
-
priority: 0
-
}
-
]
-
-
policies.each do |attrs|
-
find_or_create_by!(name: attrs[:name]) do |p|
-
p.assign_attributes(attrs)
-
end
-
end
-
end
-
# rubocop:enable Metrics/MethodLength
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Retention
-
# Tracks retention status for individual documents
-
# Links a document to its applicable policy and tracks lifecycle events
-
#
-
# Note: Documents are NEVER physically deleted - they are marked for archive/expiration
-
#
-
# rubocop:disable Metrics/ClassLength
-
class RetentionSchedule
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "retention_schedules"
-
-
# Status constants
-
STATUS_ACTIVE = "active" # Document within retention period
-
STATUS_WARNING = "warning" # Approaching expiration
-
STATUS_PENDING_ACTION = "pending" # Ready for expiration action
-
STATUS_ARCHIVED = "archived" # Archived (still accessible)
-
STATUS_EXPIRED = "expired" # Expired but preserved
-
STATUS_HELD = "held" # Under legal hold
-
-
STATUSES = [
-
STATUS_ACTIVE, STATUS_WARNING, STATUS_PENDING_ACTION,
-
STATUS_ARCHIVED, STATUS_EXPIRED, STATUS_HELD
-
].freeze
-
-
# Fields
-
field :status, type: String, default: STATUS_ACTIVE
-
field :retention_start_date, type: Time
-
field :expiration_date, type: Time
-
field :warning_date, type: Time
-
field :action_date, type: Time # When the action was taken
-
field :action_taken, type: String # What action was performed
-
-
# Tracking fields
-
field :warning_sent_at, type: Time
-
field :warning_count, type: Integer, default: 0
-
field :last_reviewed_at, type: Time
-
field :reviewed_by_id, type: BSON::ObjectId
-
-
# Notes and history
-
field :notes, type: String
-
field :history, type: Array, default: []
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ document_id: 1 }, { unique: true })
-
index({ status: 1 })
-
index({ expiration_date: 1 })
-
index({ warning_date: 1 })
-
index({ organization_id: 1, status: 1 })
-
-
# Associations
-
belongs_to :document, class_name: "Content::Document"
-
belongs_to :policy, class_name: "Retention::RetentionPolicy", optional: true
-
belongs_to :organization, class_name: "Identity::Organization"
-
has_many :legal_holds, class_name: "Retention::LegalHold", inverse_of: :schedule
-
-
# Validations
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validates :document_id, uniqueness: true
-
-
# Scopes
-
scope :active, -> { where(status: STATUS_ACTIVE) }
-
scope :warning, -> { where(status: STATUS_WARNING) }
-
scope :pending_action, -> { where(status: STATUS_PENDING_ACTION) }
-
scope :archived, -> { where(status: STATUS_ARCHIVED) }
-
scope :expired, -> { where(status: STATUS_EXPIRED) }
-
scope :held, -> { where(status: STATUS_HELD) }
-
-
scope :expiring_soon, lambda { |days = 30|
-
where(:expiration_date.lte => Time.current + days.days)
-
.where(:status.in => [STATUS_ACTIVE, STATUS_WARNING])
-
}
-
-
scope :past_expiration, lambda {
-
where(:expiration_date.lte => Time.current)
-
.where(:status.in => [STATUS_ACTIVE, STATUS_WARNING, STATUS_PENDING_ACTION])
-
}
-
-
scope :needs_warning, lambda {
-
where(:warning_date.lte => Time.current)
-
.where(status: STATUS_ACTIVE)
-
}
-
-
# Check if document is under legal hold
-
def under_legal_hold?
-
legal_holds.active.exists?
-
end
-
-
# Check if document can be modified
-
def modification_allowed?
-
!under_legal_hold? && !archived? && !expired?
-
end
-
-
# Check if document can be deleted (spoiler: never physically deleted)
-
def deletion_allowed?
-
false # Documents are NEVER physically deleted
-
end
-
-
# Status checks
-
def active?
-
status == STATUS_ACTIVE
-
end
-
-
def archived?
-
status == STATUS_ARCHIVED
-
end
-
-
def expired?
-
status == STATUS_EXPIRED
-
end
-
-
def held?
-
status == STATUS_HELD
-
end
-
-
def past_expiration?
-
expiration_date.present? && Time.current > expiration_date
-
end
-
-
def needs_warning?
-
warning_date.present? && Time.current >= warning_date && active?
-
end
-
-
# Transition to warning status
-
def mark_warning!(actor: nil)
-
return if under_legal_hold?
-
return unless active?
-
-
self.status = STATUS_WARNING
-
self.warning_sent_at = Time.current
-
self.warning_count += 1
-
-
record_history("warning_sent", actor)
-
save!
-
-
self
-
end
-
-
# Mark for pending action (ready for archive/expire)
-
def mark_pending!(actor: nil)
-
return if under_legal_hold?
-
-
self.status = STATUS_PENDING_ACTION
-
-
record_history("marked_pending", actor)
-
save!
-
-
self
-
end
-
-
# Archive the document (soft action - document still accessible)
-
def archive!(actor:, notes: nil) # rubocop:disable Naming/PredicateMethod
-
return false if under_legal_hold?
-
-
self.status = STATUS_ARCHIVED
-
self.action_date = Time.current
-
self.action_taken = RetentionPolicy::ACTION_ARCHIVE
-
self.notes = notes if notes
-
-
# Update document status
-
document.update!(retention_status: "archived")
-
-
record_history("archived", actor, notes)
-
save!
-
-
log_audit_event("document_archived", actor)
-
-
true
-
end
-
-
# Mark as expired (document preserved but flagged)
-
def expire!(actor:, notes: nil) # rubocop:disable Naming/PredicateMethod
-
return false if under_legal_hold?
-
-
self.status = STATUS_EXPIRED
-
self.action_date = Time.current
-
self.action_taken = RetentionPolicy::ACTION_EXPIRE
-
self.notes = notes if notes
-
-
# Update document status
-
document.update!(retention_status: "expired")
-
-
record_history("expired", actor, notes)
-
save!
-
-
log_audit_event("document_expired", actor)
-
-
true
-
end
-
-
# Place under legal hold
-
def place_on_hold!(reason:)
-
self.status = STATUS_HELD
-
-
record_history("placed_on_hold", nil, reason)
-
save!
-
-
self
-
end
-
-
# Release from legal hold (if no other holds exist)
-
def release_from_hold!
-
return if legal_holds.active.exists?
-
-
# Determine appropriate status
-
self.status = if past_expiration?
-
STATUS_PENDING_ACTION
-
elsif needs_warning?
-
STATUS_WARNING
-
else
-
STATUS_ACTIVE
-
end
-
-
record_history("released_from_hold", nil)
-
save!
-
-
self
-
end
-
-
# Extend retention period
-
def extend_retention!(additional_days:, actor:, reason: nil) # rubocop:disable Naming/PredicateMethod
-
return false if under_legal_hold?
-
-
old_date = expiration_date
-
self.expiration_date = expiration_date + additional_days.days
-
self.warning_date = policy.calculate_warning_date(document) if policy.warning_days&.positive?
-
-
# Reset status if was pending
-
self.status = STATUS_ACTIVE if status == STATUS_PENDING_ACTION
-
-
record_history("retention_extended", actor, "Extended by #{additional_days} days. Reason: #{reason}")
-
save!
-
-
log_audit_event("retention_extended", actor, {
-
old_expiration: old_date&.iso8601,
-
new_expiration: expiration_date.iso8601,
-
additional_days: additional_days,
-
reason: reason
-
})
-
-
true
-
end
-
-
# Record review
-
def record_review!(actor:, notes: nil)
-
self.last_reviewed_at = Time.current
-
self.reviewed_by_id = actor.id
-
-
record_history("reviewed", actor, notes)
-
save!
-
-
self
-
end
-
-
# Days until expiration
-
def days_until_expiration
-
return nil unless expiration_date
-
-
((expiration_date - Time.current) / 1.day).ceil
-
end
-
-
# Days overdue
-
def days_overdue
-
return 0 unless past_expiration?
-
-
((Time.current - expiration_date) / 1.day).floor
-
end
-
-
private
-
-
def record_history(action, actor = nil, details = nil)
-
history << {
-
"action" => action,
-
"at" => Time.current.iso8601,
-
"actor_id" => actor&.id&.to_s,
-
"actor_name" => actor&.full_name,
-
"details" => details
-
}.compact
-
end
-
-
def log_audit_event(action, actor, metadata = {})
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:record],
-
action: action,
-
target: document,
-
actor: actor,
-
metadata: metadata.merge(
-
retention_policy: policy.name,
-
schedule_id: id.to_s
-
),
-
tags: ["retention", action]
-
)
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class GeneratedDocument
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "generated_documents"
-
-
# Status values
-
DRAFT = "draft"
-
PENDING_SIGNATURES = "pending_signatures"
-
COMPLETED = "completed"
-
CANCELLED = "cancelled"
-
STATUSES = [DRAFT, PENDING_SIGNATURES, COMPLETED, CANCELLED].freeze
-
-
# Fields
-
field :name, type: String
-
field :status, type: String, default: DRAFT
-
-
# PDF file storage (GridFS)
-
field :draft_file_id, type: BSON::ObjectId # Current working PDF (may have signatures)
-
field :original_draft_file_id, type: BSON::ObjectId # Original PDF without any signatures
-
field :final_file_id, type: BSON::ObjectId # PDF with all signatures
-
field :docx_file_id, type: BSON::ObjectId # Source DOCX for local PDF generation
-
field :file_name, type: String
-
-
# PDF generation tracking (for local sync workflow)
-
field :pdf_generation_status, type: String, default: "completed"
-
# Values: "completed", "pending", "failed"
-
-
# Variable values used for generation
-
field :variable_values, type: Hash, default: {}
-
-
# Reference to the source request (certification, vacation, etc.)
-
field :source_type, type: String # "Hr::EmploymentCertificationRequest", "Hr::VacationRequest"
-
field :source_id, type: BSON::ObjectId
-
-
# Signature tracking
-
field :signatures, type: Array, default: []
-
# Each signature entry: { signatory_id, user_id, signed_at, signature_id, status }
-
-
field :completed_at, type: Time
-
field :expires_at, type: Time
-
-
# Direct employee reference (for documents generated directly for an employee)
-
field :employee_id, type: BSON::ObjectId
-
-
# Associations
-
belongs_to :template, class_name: "Templates::Template", optional: true
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :requested_by, class_name: "Identity::User"
-
belongs_to :employee, class_name: "Hr::Employee", optional: true
-
-
# Indexes
-
index({ organization_id: 1 })
-
index({ template_id: 1 })
-
index({ status: 1 })
-
index({ source_type: 1, source_id: 1 })
-
index({ requested_by_id: 1 })
-
index({ employee_id: 1 })
-
index({ created_at: -1 })
-
-
# Validations
-
validates :name, presence: true, length: { maximum: 255 }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
-
# Scopes
-
scope :draft, -> { where(status: DRAFT) }
-
scope :pending_signatures, -> { where(status: PENDING_SIGNATURES) }
-
scope :completed, -> { where(status: COMPLETED) }
-
scope :cancelled, -> { where(status: CANCELLED) }
-
scope :for_user, ->(user) { where(requested_by_id: user.id) }
-
scope :pending_pdf_generation, -> { where(pdf_generation_status: "pending") }
-
scope :pending_signature_by, lambda { |user|
-
where(
-
status: PENDING_SIGNATURES,
-
"signatures.user_id" => user.id.to_s,
-
"signatures.status" => "pending"
-
)
-
}
-
-
# Instance methods
-
def draft?
-
status == DRAFT
-
end
-
-
def pending_signatures?
-
status == PENDING_SIGNATURES
-
end
-
-
def completed?
-
status == COMPLETED
-
end
-
-
def cancelled?
-
status == CANCELLED
-
end
-
-
def source
-
return nil unless source_type && source_id
-
-
source_type.constantize.find(source_id)
-
rescue Mongoid::Errors::DocumentNotFound
-
nil
-
end
-
-
def source=(record)
-
return if record.nil?
-
-
self.source_type = record.class.name
-
self.source_id = record.id
-
end
-
-
# Initialize signature tracking from template signatories
-
def initialize_signatures!
-
return unless template
-
-
self.signatures = template.signatories.by_position.map do |sig|
-
user = sig.find_signatory_for(signature_context)
-
-
{
-
"signatory_id" => sig.uuid,
-
"signatory_type_code" => sig.signatory_type_code,
-
"signatory_role" => sig.role,
-
"signatory_label" => sig.label,
-
"label" => sig.label,
-
"user_id" => user&.id&.to_s,
-
"user_name" => user&.full_name,
-
"required" => sig.required,
-
"status" => "pending",
-
"signature_id" => nil,
-
"signed_at" => nil,
-
"signed_by_name" => nil
-
}
-
end
-
-
update!(status: PENDING_SIGNATURES) if signatures.any?
-
end
-
-
# Apply a user's signature
-
# custom_position: { x:, y:, width:, height: } - optional override for signature position
-
def sign!(user:, signature:, custom_position: nil)
-
sig_entry = find_pending_signature_for(user)
-
-
raise SignatureError, "No hay firma pendiente para este usuario" unless sig_entry
-
raise SignatureError, "Usuario no tiene firma digital configurada" unless signature
-
-
# Check signature order if sequential signing is enabled
-
unless can_sign_at_position?(sig_entry)
-
blocking = blocking_signatures_for(sig_entry)
-
waiting_names = blocking.map { |b| b["signatory_label"] || b["label"] }.join(", ")
-
raise SignatureError, "Debe esperar las firmas de: #{waiting_names}"
-
end
-
-
sig_entry["signature_id"] = signature.uuid
-
sig_entry["signed_at"] = Time.current.iso8601
-
sig_entry["signed_by_name"] = user.full_name
-
sig_entry["status"] = "signed"
-
-
# Store custom position if provided
-
if custom_position.present?
-
sig_entry["custom_x"] = custom_position[:x] if custom_position[:x]
-
sig_entry["custom_y"] = custom_position[:y] if custom_position[:y]
-
sig_entry["custom_width"] = custom_position[:width] if custom_position[:width]
-
sig_entry["custom_height"] = custom_position[:height] if custom_position[:height]
-
end
-
-
save!
-
-
# Apply signature to PDF immediately (don't wait for all signatures)
-
apply_signature_to_pdf!(sig_entry, signature)
-
-
# Check if all required signatures are complete
-
check_completion!
-
end
-
-
# Apply a single signature to the current PDF
-
def apply_signature_to_pdf!(sig_entry, signature)
-
signatory_uuid = sig_entry["signatory_id"]
-
signatory = signatory_uuid.present? ? template&.signatories&.where(uuid: signatory_uuid)&.first : nil
-
return unless signatory
-
-
pdf_content = file_content
-
return unless pdf_content
-
-
# Create working files
-
input_pdf = Tempfile.new(["input", ".pdf"])
-
input_pdf.binmode
-
input_pdf.write(pdf_content)
-
input_pdf.rewind
-
-
begin
-
# Load the PDF
-
pdf = CombinePDF.load(input_pdf.path)
-
-
# Get page dimensions to calculate correct page from absolute Y
-
first_page = pdf.pages.first
-
page_height = first_page.mediabox[3].to_f
-
-
# Calculate which page the signature should go on based on absolute Y
-
absolute_y = signatory.y_position.to_f
-
calculated_page = (absolute_y / page_height).floor + 1
-
relative_y = absolute_y % page_height
-
-
# Use calculated page, but respect explicit page_number if Y is within first page
-
page_index = if absolute_y < page_height && signatory.page_number
-
(signatory.page_number || 1) - 1
-
else
-
calculated_page - 1
-
end
-
page_index = [[page_index, 0].max, pdf.pages.count - 1].min
-
target_page = pdf.pages[page_index]
-
-
Rails.logger.info "Signature placement: absoluteY=#{absolute_y}, pageHeight=#{page_height}, calculatedPage=#{calculated_page}, relativeY=#{relative_y}, pageIndex=#{page_index}"
-
-
# Get signature image
-
renderer = Templates::SignatureRendererService.new(signature)
-
img_tempfile = renderer.to_tempfile
-
-
begin
-
# Create signature overlay with relative Y position for this page
-
overlay_pdf = create_signature_overlay_for(
-
img_path: img_tempfile.path,
-
signatory: signatory,
-
sig_entry: sig_entry,
-
page_width: target_page.mediabox[2],
-
page_height: target_page.mediabox[3],
-
relative_y: relative_y
-
)
-
-
# Merge overlay onto page
-
overlay = CombinePDF.parse(overlay_pdf)
-
target_page << overlay.pages.first
-
-
# Save updated PDF
-
output_pdf = Tempfile.new(["updated", ".pdf"])
-
pdf.save(output_pdf.path)
-
-
# Update in GridFS (replace draft with signed version)
-
store_updated_pdf(File.binread(output_pdf.path))
-
ensure
-
img_tempfile.close
-
img_tempfile.unlink
-
output_pdf&.close
-
output_pdf&.unlink
-
end
-
ensure
-
input_pdf.close
-
input_pdf.unlink
-
end
-
rescue StandardError => e
-
Rails.logger.error("Error applying signature to PDF: #{e.message}")
-
Rails.logger.error(e.backtrace.first(5).join("\n"))
-
end
-
-
def create_signature_overlay_for(img_path:, signatory:, sig_entry:, page_width:, page_height:, relative_y: nil)
-
box = signatory.signature_box
-
-
# Use custom position from sig_entry if available, otherwise use template defaults
-
x = sig_entry["custom_x"] || box[:x]
-
base_y = sig_entry["custom_y"] || box[:y]
-
# Use relative_y if provided (for multi-page documents), otherwise use base_y
-
y = relative_y || base_y
-
width = sig_entry["custom_width"] || box[:width]
-
height = sig_entry["custom_height"] || box[:height]
-
date_position = box[:date_position] || "right"
-
show_label = box[:show_label].nil? ? true : box[:show_label]
-
show_signer_name = box[:show_signer_name] || false
-
-
pdf = Prawn::Document.new(
-
page_size: [page_width, page_height],
-
margin: 0
-
)
-
-
# Calculate text space needed below signature
-
text_lines = 0
-
text_lines += 1 if show_label
-
text_lines += 1 if show_signer_name
-
text_space = text_lines * 10
-
-
# Calculate signature dimensions based on date position
-
# This ensures the preview matches what's rendered
-
sig_width, sig_height, sig_y_offset = case date_position
-
when "right"
-
# Fecha a la derecha: firma usa 75% del ancho
-
[width * 0.75, height - text_space, 0]
-
when "below"
-
# Fecha debajo: firma usa 100% ancho, 80% alto, fecha en el 20% inferior
-
[width, (height - text_space) * 0.80, (height - text_space) * 0.20]
-
when "above"
-
# Fecha arriba: firma usa 100% ancho, 80% alto, firma en el 80% inferior
-
[width, (height - text_space) * 0.80, 0]
-
when "none"
-
# Sin fecha: firma usa 100% del espacio
-
[width, height - text_space, 0]
-
else
-
[width * 0.75, height - text_space, 0]
-
end
-
-
# Calculate position from bottom (Prawn uses bottom-left origin)
-
# y is distance from TOP of page to TOP of signature box
-
# Prawn's image at: [x, y] positions the TOP-LEFT of the image at (x, y) from bottom-left origin
-
# So we need: y_position_from_bottom = page_height - y_from_top
-
sig_top_from_bottom = page_height - y + sig_y_offset
-
-
Rails.logger.info "Signature overlay: x=#{x}, y=#{y}, sig_top_from_bottom=#{sig_top_from_bottom}, page_height=#{page_height}, date_position=#{date_position}, show_label=#{show_label}"
-
-
# Draw signature image - fit maintains aspect ratio within the specified dimensions
-
# at: positions TOP-LEFT corner of image at given coordinates
-
pdf.image img_path, at: [x, sig_top_from_bottom], fit: [sig_width, sig_height]
-
-
# Add optional label and signer name below signature
-
# Position text below the signature (signature bottom = sig_top - sig_height)
-
current_y = sig_top_from_bottom - sig_height - 3
-
-
if show_label
-
pdf.fill_color "333333"
-
pdf.draw_text signatory.label, at: [x, current_y], size: 7
-
pdf.fill_color "000000"
-
current_y -= 10
-
end
-
-
if show_signer_name && sig_entry["signed_by_name"].present?
-
pdf.fill_color "666666"
-
pdf.draw_text sig_entry["signed_by_name"], at: [x, current_y], size: 6
-
pdf.fill_color "000000"
-
end
-
-
# Add date based on position setting
-
unless date_position == "none"
-
pdf.fill_color "666666"
-
signed_at = sig_entry["signed_at"]
-
date_str = signed_at ? Time.parse(signed_at).strftime("%d/%m/%Y") : ""
-
time_str = signed_at ? Time.parse(signed_at).strftime("%H:%M") : ""
-
-
# Calculate signature center Y for positioning date
-
sig_center_y = sig_top_from_bottom - (sig_height / 2)
-
-
case date_position
-
when "right"
-
# Fecha a la derecha de la firma (vertical, centrada)
-
date_x = x + sig_width + 5
-
pdf.draw_text date_str, at: [date_x, sig_center_y + 5], size: 7
-
pdf.draw_text time_str, at: [date_x, sig_center_y - 7], size: 6
-
when "below"
-
# Fecha debajo de la firma (horizontal, centrada)
-
date_text = "#{date_str} #{time_str}"
-
date_x = x + (width / 2) - 25
-
date_y = sig_top_from_bottom - sig_height - 15
-
pdf.draw_text date_text, at: [date_x, date_y], size: 7
-
when "above"
-
# Fecha arriba de la firma (horizontal, centrada)
-
date_text = "#{date_str} #{time_str}"
-
date_x = x + (width / 2) - 25
-
date_y = sig_top_from_bottom + 5
-
pdf.draw_text date_text, at: [date_x, date_y], size: 7
-
end
-
pdf.fill_color "000000"
-
end
-
-
pdf.render
-
end
-
-
def store_updated_pdf(pdf_content)
-
file_name = file_name_base + "-signed.pdf"
-
pdf_file = Mongoid::GridFs.put(
-
StringIO.new(pdf_content),
-
filename: file_name,
-
content_type: "application/pdf"
-
)
-
-
# Keep as draft_file_id to maintain the workflow
-
# Delete old file if exists
-
Mongoid::GridFs.delete(draft_file_id) if draft_file_id
-
update!(draft_file_id: pdf_file.id)
-
end
-
-
def file_name_base
-
file_name&.gsub(/\.pdf$/i, "") || "document"
-
end
-
-
def pending_signatures_count
-
signatures.count { |s| s["status"] == "pending" && s["required"] }
-
end
-
-
def completed_signatures_count
-
signatures.count { |s| s["status"] == "signed" }
-
end
-
-
def total_required_signatures
-
signatures.count { |s| s["required"] }
-
end
-
-
def all_required_signed?
-
signatures.select { |s| s["required"] }.all? { |s| s["status"] == "signed" }
-
end
-
-
def can_be_signed_by?(user)
-
signatures.any? do |s|
-
s["user_id"] == user.id.to_s && s["status"] == "pending" && can_sign_at_position?(s)
-
end
-
end
-
-
# Check if sequential signing is enabled for this document's template
-
def sequential_signing?
-
template&.sequential_signing != false
-
end
-
-
# Check if a signature at a given position can be signed now
-
# (all previous required signatures must be completed)
-
def can_sign_at_position?(sig_entry)
-
return true unless sequential_signing?
-
-
sig_index = signatures.index(sig_entry)
-
return true if sig_index.nil? || sig_index.zero?
-
-
# Check all previous required signatures are signed
-
signatures[0...sig_index].all? do |prev_sig|
-
!prev_sig["required"] || prev_sig["status"] == "signed"
-
end
-
end
-
-
# Get the signatures that are blocking a given signature
-
def blocking_signatures_for(sig_entry)
-
return [] unless sequential_signing?
-
-
sig_index = signatures.index(sig_entry)
-
return [] if sig_index.nil? || sig_index.zero?
-
-
# Return all previous required signatures that are not signed
-
signatures[0...sig_index].select do |prev_sig|
-
prev_sig["required"] && prev_sig["status"] != "signed"
-
end
-
end
-
-
# Get signature status with order information
-
def signature_with_order_status(sig_entry)
-
can_sign = can_sign_at_position?(sig_entry)
-
blocking = blocking_signatures_for(sig_entry)
-
-
{
-
can_sign_now: can_sign,
-
waiting_for: blocking.map { |b| b["signatory_label"] || b["label"] },
-
waiting_count: blocking.count
-
}
-
end
-
-
# Get next signatory who can sign
-
def next_signatory_to_sign
-
return nil unless pending_signatures?
-
-
pending_signatories.find { |sig| can_sign_at_position?(sig) }
-
end
-
-
def pending_signatories
-
signatures.select { |s| s["status"] == "pending" }
-
end
-
-
def signed_signatories
-
signatures.select { |s| s["status"] == "signed" }
-
end
-
-
# Get the current file (final if completed, draft otherwise)
-
def current_file_id
-
completed? && final_file_id ? final_file_id : draft_file_id
-
end
-
-
def file_content
-
file_id = current_file_id
-
return nil unless file_id
-
-
file = Mongoid::GridFs.get(file_id)
-
file.data
-
rescue StandardError => e
-
Rails.logger.error "Error reading generated document from GridFS: #{e.message}"
-
nil
-
end
-
-
def docx_content
-
return nil unless docx_file_id
-
-
file = Mongoid::GridFs.get(docx_file_id)
-
file.data
-
rescue StandardError => e
-
Rails.logger.error "Error reading DOCX from GridFS: #{e.message}"
-
nil
-
end
-
-
def pending_pdf?
-
pdf_generation_status == "pending"
-
end
-
-
def store_pdf_from_sync!(pdf_content)
-
file_name = "#{name.parameterize}.pdf"
-
pdf_file = Mongoid::GridFs.put(
-
StringIO.new(pdf_content),
-
filename: file_name,
-
content_type: "application/pdf"
-
)
-
-
# Store as both draft and original (original never gets modified)
-
update!(
-
draft_file_id: pdf_file.id,
-
original_draft_file_id: pdf_file.id,
-
pdf_generation_status: "completed"
-
)
-
-
# Initialize signatures now that we have a PDF
-
initialize_signatures!
-
end
-
-
# Reset signatures and restore original PDF without any signatures
-
def reset_signatures!
-
# Restore original PDF if available
-
if original_draft_file_id.present?
-
# Copy original to draft (don't modify original)
-
original_content = Mongoid::GridFs.get(original_draft_file_id).data
-
new_draft = Mongoid::GridFs.put(
-
StringIO.new(original_content),
-
filename: file_name || "document.pdf",
-
content_type: "application/pdf"
-
)
-
self.draft_file_id = new_draft.id
-
end
-
-
# Reset all signatures to pending
-
signatures.each do |s|
-
s["status"] = "pending"
-
s["signature_id"] = nil
-
s["signed_at"] = nil
-
s["signed_by_name"] = nil
-
end
-
-
self.status = PENDING_SIGNATURES
-
self.final_file_id = nil
-
self.completed_at = nil
-
save!
-
end
-
-
def cancel!(reason: nil)
-
update!(
-
status: CANCELLED,
-
variable_values: variable_values.merge("cancellation_reason" => reason)
-
)
-
end
-
-
private
-
-
def signature_context
-
src = source
-
# Use direct employee reference if available, otherwise try to get from source
-
emp = employee || (src.respond_to?(:employee) ? src.employee : nil)
-
-
{
-
employee: emp,
-
organization: organization,
-
request: src
-
}
-
end
-
-
def find_pending_signature_for(user)
-
signatures.find do |s|
-
s["user_id"] == user.id.to_s && s["status"] == "pending"
-
end
-
end
-
-
def check_completion!
-
return unless all_required_signed?
-
-
# Generate final PDF with all signatures (non-blocking on error)
-
begin
-
Templates::PdfSignatureService.new(self).apply_all_signatures!
-
rescue StandardError => e
-
Rails.logger.error("Error generating final PDF: #{e.message}")
-
Rails.logger.error(e.backtrace.first(5).join("\n"))
-
# Continue to mark as completed even if PDF generation fails
-
end
-
-
update!(status: COMPLETED, completed_at: Time.current)
-
end
-
-
class SignatureError < StandardError; end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class SignatoryType
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "signatory_types"
-
-
# Fields
-
field :name, type: String # Display name, e.g., "Gerente de RR.HH."
-
field :code, type: String # Unique code, e.g., "hr_manager"
-
field :description, type: String # Description of this signatory type
-
field :is_system, type: Boolean, default: false # System types can't be deleted
-
field :active, type: Boolean, default: true
-
field :position, type: Integer, default: 0
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization", optional: true
-
belongs_to :created_by, class_name: "Identity::User", optional: true
-
has_many :template_signatories, class_name: "Templates::TemplateSignatory",
-
foreign_key: :signatory_type_id, dependent: :restrict_with_error
-
-
# Indexes
-
index({ organization_id: 1, active: 1 })
-
index({ code: 1 }, { unique: true })
-
index({ is_system: 1 })
-
index({ position: 1 })
-
-
# Validations
-
validates :name, presence: true, length: { maximum: 100 }
-
validates :code, presence: true, uniqueness: true, length: { maximum: 50 }
-
-
validate :code_format_valid
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :inactive, -> { where(active: false) }
-
scope :system_types, -> { where(is_system: true) }
-
scope :custom_types, -> { where(is_system: false) }
-
scope :for_organization, ->(org) { where(:organization_id.in => [nil, org&.id]) }
-
scope :ordered, -> { order(position: :asc, name: :asc) }
-
-
# Callbacks
-
before_validation :generate_code, on: :create, if: -> { code.blank? }
-
-
# Instance methods
-
def system?
-
is_system
-
end
-
-
def custom?
-
!is_system
-
end
-
-
def activate!
-
update!(active: true)
-
end
-
-
def deactivate!
-
update!(active: false)
-
end
-
-
def toggle_active!
-
update!(active: !active)
-
end
-
-
def in_use?
-
template_signatories.exists?
-
end
-
-
def usage_count
-
template_signatories.count
-
end
-
-
# Class methods
-
class << self
-
def available_for(organization)
-
active.for_organization(organization).ordered
-
end
-
-
def seed_system_types!
-
system_types_data.each do |data|
-
find_or_create_by!(code: data[:code]) do |type|
-
type.assign_attributes(data.merge(is_system: true))
-
end
-
end
-
end
-
-
private
-
-
def system_types_data
-
[
-
{ name: "Empleado Solicitante", code: "employee", description: "El empleado que realiza la solicitud", position: 1 },
-
{ name: "Supervisor Directo", code: "supervisor", description: "Supervisor inmediato del empleado", position: 2 },
-
{ name: "Recursos Humanos", code: "hr", description: "Personal de Recursos Humanos", position: 3 },
-
{ name: "Gerente de RR.HH.", code: "hr_manager", description: "Gerente del departamento de Recursos Humanos", position: 4 },
-
{ name: "Departamento Legal", code: "legal", description: "Personal del departamento legal", position: 5 },
-
{ name: "Gerente General", code: "general_manager", description: "Gerente general de la empresa", position: 6 },
-
{ name: "Representante Legal", code: "legal_representative", description: "Representante legal de la empresa", position: 7 },
-
{ name: "Contador", code: "accountant", description: "Contador o área contable", position: 8 },
-
{ name: "Administrador", code: "admin", description: "Administrador del sistema", position: 9 }
-
]
-
end
-
end
-
-
private
-
-
def generate_code
-
base = name.to_s.parameterize(separator: "_")
-
self.code = base
-
end
-
-
def code_format_valid
-
return if code.blank?
-
-
unless code.match?(/\A[a-z][a-z0-9_]*\z/)
-
errors.add(:code, "debe comenzar con letra y contener solo letras minúsculas, números y guiones bajos")
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class Template
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "templates"
-
-
# Modules (which system module this template belongs to)
-
MODULES = {
-
"hr" => { label: "Recursos Humanos", icon: "users" },
-
"legal" => { label: "Gestión Legal", icon: "scale" },
-
"admin" => { label: "Administración", icon: "settings" }
-
}.freeze
-
-
# Mapping from main_category to default module
-
CATEGORY_TO_MODULE = {
-
"laboral" => "hr",
-
"comercial" => "legal",
-
"administrativo" => "admin"
-
}.freeze
-
-
# Main categories (top level)
-
MAIN_CATEGORIES = {
-
"laboral" => "Laboral",
-
"comercial" => "Comercial",
-
"administrativo" => "Administrativo"
-
}.freeze
-
-
# Subcategories for templates (grouped by main category)
-
SUBCATEGORIES = {
-
"certification" => { label: "Certificaciones", main: "laboral" },
-
"vacation" => { label: "Vacaciones", main: "laboral" },
-
"contract" => { label: "Contratos", main: "laboral" },
-
"termination" => { label: "Terminación", main: "laboral" },
-
"memo" => { label: "Memorandos", main: "administrativo" },
-
"letter" => { label: "Cartas", main: "administrativo" },
-
"policy" => { label: "Políticas", main: "administrativo" },
-
"commercial_contract" => { label: "Contratos Comerciales", main: "comercial" },
-
"proposal" => { label: "Propuestas", main: "comercial" },
-
"agreement" => { label: "Acuerdos", main: "comercial" },
-
"nda" => { label: "NDA/Confidencialidad", main: "comercial" },
-
"other" => { label: "Otros", main: "administrativo" }
-
}.freeze
-
-
# Legacy alias for backward compatibility
-
CATEGORIES = SUBCATEGORIES.transform_values { |v| v[:label] }.freeze
-
-
# Status values
-
DRAFT = "draft"
-
ACTIVE = "active"
-
ARCHIVED = "archived"
-
STATUSES = [DRAFT, ACTIVE, ARCHIVED].freeze
-
-
# Fields
-
field :name, type: String
-
field :description, type: String
-
field :module_type, type: String, default: "hr" # hr, legal, admin
-
field :main_category, type: String, default: "laboral"
-
field :category, type: String, default: "other" # This is now the subcategory
-
field :status, type: String, default: DRAFT
-
field :version, type: Integer, default: 1
-
-
# File storage (GridFS file ID)
-
field :file_id, type: BSON::ObjectId
-
field :file_name, type: String
-
field :file_content_type, type: String
-
field :file_size, type: Integer
-
-
# PDF preview file (generated from Word for preview on servers without LibreOffice)
-
field :preview_file_id, type: BSON::ObjectId
-
-
# Extracted variables from template
-
field :variables, type: Array, default: []
-
-
# Variable mappings: { "Nombre Empleado" => "employee.full_name", ... }
-
field :variable_mappings, type: Hash, default: {}
-
-
# Default third party type for this template (provider, client, contractor, partner, other)
-
field :default_third_party_type, type: String
-
-
# For certification templates: which certification type this template is for
-
# Maps to Hr::EmploymentCertificationRequest::CERTIFICATION_TYPES
-
# (employment, salary, position, full, custom)
-
field :certification_type, type: String
-
-
# Preview settings for signature positioning
-
field :preview_scale, type: Float, default: 0.7
-
field :preview_page_height, type: Integer, default: 792 # Letter size height
-
-
# Actual PDF dimensions (extracted from uploaded file)
-
field :pdf_width, type: Float
-
field :pdf_height, type: Float
-
field :pdf_page_count, type: Integer, default: 1
-
-
# Signature workflow options
-
# When true, signatories must sign in order (by position)
-
# Each signatory can only sign after all previous signatories have signed
-
field :sequential_signing, type: Boolean, default: true
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :created_by, class_name: "Identity::User", optional: true
-
has_many :signatories, class_name: "Templates::TemplateSignatory", dependent: :destroy
-
has_many :generated_documents, class_name: "Templates::GeneratedDocument", dependent: :nullify
-
-
# Indexes
-
index({ organization_id: 1 })
-
index({ module_type: 1 })
-
index({ main_category: 1 })
-
index({ category: 1 })
-
index({ status: 1 })
-
index({ name: 1 })
-
index({ organization_id: 1, module_type: 1, main_category: 1, category: 1, status: 1 })
-
index({ organization_id: 1, category: 1, certification_type: 1, status: 1 })
-
-
# Validations
-
validates :name, presence: true, length: { maximum: 200 }
-
validates :module_type, presence: true, inclusion: { in: MODULES.keys }
-
validates :main_category, presence: true, inclusion: { in: MAIN_CATEGORIES.keys }
-
validates :category, presence: true, inclusion: { in: SUBCATEGORIES.keys }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validates :file_id, presence: true, if: -> { active? }
-
-
# Callbacks
-
before_validation :infer_module_from_category, if: -> { main_category_changed? && module_type.blank? }
-
-
# Scopes
-
scope :draft, -> { where(status: DRAFT) }
-
scope :active, -> { where(status: ACTIVE) }
-
scope :archived, -> { where(status: ARCHIVED) }
-
scope :by_module, ->(mod) { where(module_type: mod) }
-
scope :for_hr, -> { where(module_type: "hr") }
-
scope :for_legal, -> { where(module_type: "legal") }
-
scope :for_admin, -> { where(module_type: "admin") }
-
scope :by_main_category, ->(main_cat) { where(main_category: main_cat) }
-
scope :by_category, ->(category) { where(category: category) }
-
scope :by_subcategory, ->(subcategory) { where(category: subcategory) }
-
scope :for_organization, ->(org) { where(organization_id: org.id) }
-
scope :for_certification_type, ->(cert_type) { where(certification_type: cert_type) }
-
-
# Instance methods
-
def draft?
-
status == DRAFT
-
end
-
-
def active?
-
status == ACTIVE
-
end
-
-
def archived?
-
status == ARCHIVED
-
end
-
-
def activate!
-
raise InvalidStateError, "Template debe tener archivo adjunto para activar" unless file_id
-
-
update!(status: ACTIVE)
-
end
-
-
def archive!
-
update!(status: ARCHIVED)
-
end
-
-
def reactivate!
-
update!(status: ACTIVE)
-
end
-
-
def duplicate!
-
dup.tap do |new_template|
-
new_template.name = "#{name} (copia)"
-
new_template.status = DRAFT
-
new_template.version = 1
-
new_template.uuid = nil
-
new_template.save!
-
-
# Duplicate signatories
-
signatories.each do |sig|
-
new_sig = sig.dup
-
new_sig.template = new_template
-
new_sig.uuid = nil
-
new_sig.save!
-
end
-
end
-
end
-
-
def module_type_label
-
MODULES.dig(module_type, :label) || module_type
-
end
-
-
def module_type_icon
-
MODULES.dig(module_type, :icon) || "file"
-
end
-
-
def main_category_label
-
MAIN_CATEGORIES[main_category] || main_category
-
end
-
-
def category_label
-
SUBCATEGORIES.dig(category, :label) || category
-
end
-
-
# Alias for clarity
-
def subcategory_label
-
category_label
-
end
-
-
# Infer module_type from main_category
-
def infer_module_from_category
-
self.module_type = CATEGORY_TO_MODULE[main_category] || "admin"
-
end
-
-
# Infer main_category from subcategory if not set
-
def infer_main_category!
-
return if main_category.present?
-
self.main_category = SUBCATEGORIES.dig(category, :main) || "administrativo"
-
end
-
-
def required_signatories
-
signatories.required
-
end
-
-
def optional_signatories
-
signatories.optional
-
end
-
-
# File handling with GridFS
-
def attach_file(io, filename:, content_type:)
-
# Ensure we read the IO content
-
io.rewind if io.respond_to?(:rewind)
-
content = io.read
-
io.rewind if io.respond_to?(:rewind)
-
-
# Store in GridFS
-
file = Mongoid::GridFs.put(
-
StringIO.new(content),
-
filename: filename,
-
content_type: content_type
-
)
-
-
self.file_id = file.id
-
self.file_name = filename
-
self.file_content_type = content_type
-
self.file_size = content.bytesize
-
-
# Extract variables from the uploaded document
-
extract_variables! if file_name&.end_with?(".docx")
-
-
# Extract PDF dimensions after saving (need to convert docx to PDF first if needed)
-
extract_pdf_dimensions!
-
-
save!
-
end
-
-
def file_content
-
return nil unless file_id
-
-
file = Mongoid::GridFs.get(file_id)
-
file.data
-
rescue StandardError => e
-
Rails.logger.error "Error reading file from GridFS: #{e.message}"
-
nil
-
end
-
-
def extract_variables!
-
return unless file_id
-
-
content = file_content
-
return unless content
-
-
# Use TemplateParserService to extract variables
-
self.variables = TemplateParserService.new(content).extract_variables
-
-
# Auto-assign mappings from system variables
-
auto_assign_mappings!
-
end
-
-
def extract_pdf_dimensions!
-
return unless file_id
-
-
begin
-
content = file_content
-
return unless content
-
-
# Get PDF content - either directly or by converting docx
-
pdf_content = if file_name&.end_with?(".pdf")
-
content
-
elsif file_name&.end_with?(".docx")
-
convert_docx_to_pdf_for_dimensions(content)
-
end
-
-
return unless pdf_content
-
-
# Store the PDF preview in GridFS for servers without LibreOffice
-
if file_name&.end_with?(".docx")
-
store_pdf_preview!(pdf_content)
-
end
-
-
require "combine_pdf"
-
pdf = CombinePDF.parse(pdf_content)
-
return if pdf.pages.empty?
-
-
first_page = pdf.pages.first
-
mediabox = first_page.mediabox
-
-
self.pdf_width = mediabox[2].to_f
-
self.pdf_height = mediabox[3].to_f
-
self.pdf_page_count = pdf.pages.count
-
-
# Also update preview_page_height to match actual PDF
-
self.preview_page_height = pdf_height.to_i if pdf_height.present?
-
-
Rails.logger.info "Extracted PDF dimensions: #{pdf_width}x#{pdf_height}, #{pdf_page_count} pages"
-
rescue StandardError => e
-
Rails.logger.warn "Could not extract PDF dimensions: #{e.message}"
-
Rails.logger.warn e.backtrace.first(3).join("\n")
-
# Set default Letter size if extraction fails
-
self.pdf_width ||= 612.0
-
self.pdf_height ||= 792.0
-
self.pdf_page_count ||= 1
-
end
-
end
-
-
def store_pdf_preview!(pdf_content)
-
return unless pdf_content
-
-
# Delete old preview if exists
-
if preview_file_id
-
begin
-
Mongoid::GridFs.delete(preview_file_id)
-
rescue StandardError
-
nil
-
end
-
end
-
-
# Store new PDF preview
-
preview_filename = file_name&.sub(/\.docx$/i, ".pdf") || "preview.pdf"
-
file = Mongoid::GridFs.put(
-
StringIO.new(pdf_content),
-
filename: preview_filename,
-
content_type: "application/pdf"
-
)
-
-
self.preview_file_id = file.id
-
Rails.logger.info "Stored PDF preview: #{preview_filename} (#{pdf_content.bytesize} bytes)"
-
end
-
-
def preview_content
-
return nil unless preview_file_id
-
-
file = Mongoid::GridFs.get(preview_file_id)
-
file.data
-
rescue Mongoid::Errors::DocumentNotFound
-
nil
-
end
-
-
def convert_docx_to_pdf_for_dimensions(docx_content)
-
require "tempfile"
-
require "fileutils"
-
-
# Write DOCX to temp file
-
docx_temp = Tempfile.new(["template", ".docx"])
-
docx_temp.binmode
-
docx_temp.write(docx_content)
-
docx_temp.close
-
-
temp_dir = Dir.mktmpdir
-
-
begin
-
# Find LibreOffice
-
soffice_path = `which soffice`.strip
-
soffice_path = "/opt/homebrew/bin/soffice" if soffice_path.empty? && File.exist?("/opt/homebrew/bin/soffice")
-
soffice_path = "/usr/bin/soffice" if soffice_path.empty? && File.exist?("/usr/bin/soffice")
-
-
unless File.exist?(soffice_path.to_s)
-
Rails.logger.warn "LibreOffice not found for PDF conversion"
-
return nil
-
end
-
-
# Convert to PDF
-
system(soffice_path, "--headless", "--convert-to", "pdf", "--outdir", temp_dir, docx_temp.path)
-
-
pdf_path = File.join(temp_dir, File.basename(docx_temp.path).sub(".docx", ".pdf"))
-
-
return nil unless File.exist?(pdf_path)
-
-
File.binread(pdf_path)
-
ensure
-
docx_temp.unlink
-
FileUtils.rm_rf(temp_dir)
-
end
-
end
-
-
# Auto-assign template variables to system mappings based on name equivalence
-
def auto_assign_mappings!
-
return if variables.blank?
-
-
# Get all available mappings for this organization
-
available_mappings = VariableMapping.for_organization(organization).active.to_a
-
-
variables.each do |variable|
-
# Skip if already mapped
-
next if variable_mappings[variable].present?
-
-
# Find matching system mapping using VariableNormalizer.equivalent?
-
matching_mapping = available_mappings.find do |vm|
-
VariableNormalizer.equivalent?(variable, vm.name)
-
end
-
-
if matching_mapping
-
variable_mappings[variable] = matching_mapping.key
-
end
-
end
-
-
save if changed?
-
end
-
-
# Re-assign all mappings (even existing ones) from system variables
-
def reassign_all_mappings!
-
return if variables.blank?
-
-
available_mappings = VariableMapping.for_organization(organization).active.to_a
-
new_mappings = {}
-
-
variables.each do |variable|
-
matching_mapping = available_mappings.find do |vm|
-
VariableNormalizer.equivalent?(variable, vm.name)
-
end
-
-
if matching_mapping
-
new_mappings[variable] = matching_mapping.key
-
elsif variable_mappings[variable].present?
-
# Keep existing custom mapping
-
new_mappings[variable] = variable_mappings[variable]
-
end
-
end
-
-
update!(variable_mappings: new_mappings)
-
end
-
-
# Get available variable mappings from database
-
def self.available_variable_mappings(organization = nil)
-
VariableMapping.to_mapping_hash(organization)
-
end
-
-
# Get grouped mappings for UI
-
def self.grouped_variable_mappings(organization = nil)
-
VariableMapping.grouped_for(organization)
-
end
-
-
# Get required third party fields based on template variables
-
# Returns array of field info: [{ key: "business_name", label: "Razón Social", required: true }, ...]
-
def required_third_party_fields
-
return [] if variables.blank?
-
-
# Map of variable keys to third party fields
-
variable_to_field_map = {
-
"third_party.display_name" => { field: "business_name", label: "Razón Social/Nombre", person_type: nil },
-
"third_party.business_name" => { field: "business_name", label: "Razón Social", person_type: "juridical" },
-
"third_party.trade_name" => { field: "trade_name", label: "Nombre Comercial", person_type: nil },
-
"third_party.first_name" => { field: "first_name", label: "Nombre", person_type: "natural" },
-
"third_party.last_name" => { field: "last_name", label: "Apellido", person_type: "natural" },
-
"third_party.identification_number" => { field: "identification_number", label: "Número de Identificación", person_type: nil },
-
"third_party.identification_type" => { field: "identification_type", label: "Tipo de Identificación", person_type: nil },
-
"third_party.full_identification" => { field: "identification_number", label: "Identificación Completa", person_type: nil },
-
"third_party.verification_digit" => { field: "verification_digit", label: "Dígito de Verificación", person_type: "juridical" },
-
"third_party.email" => { field: "email", label: "Correo Electrónico", person_type: nil },
-
"third_party.phone" => { field: "phone", label: "Teléfono", person_type: nil },
-
"third_party.mobile" => { field: "mobile", label: "Celular", person_type: nil },
-
"third_party.address" => { field: "address", label: "Dirección", person_type: nil },
-
"third_party.city" => { field: "city", label: "Ciudad", person_type: nil },
-
"third_party.state" => { field: "state", label: "Departamento/Estado", person_type: nil },
-
"third_party.country" => { field: "country", label: "País", person_type: nil },
-
"third_party.legal_rep_name" => { field: "legal_rep_name", label: "Nombre Representante Legal", person_type: "juridical" },
-
"third_party.legal_rep_id" => { field: "legal_rep_id_number", label: "Cédula Representante Legal", person_type: "juridical" },
-
"third_party.legal_rep_id_number" => { field: "legal_rep_id_number", label: "Cédula Representante Legal", person_type: "juridical" },
-
"third_party.legal_rep_id_type" => { field: "legal_rep_id_type", label: "Tipo ID Representante Legal", person_type: "juridical" },
-
"third_party.legal_rep_id_city" => { field: "legal_rep_id_city", label: "Ciudad Expedición Cédula Rep. Legal", person_type: "juridical" },
-
"third_party.legal_rep_email" => { field: "legal_rep_email", label: "Email Representante Legal", person_type: "juridical" },
-
"third_party.legal_rep_phone" => { field: "legal_rep_phone", label: "Teléfono Representante Legal", person_type: "juridical" },
-
"third_party.bank_name" => { field: "bank_name", label: "Banco", person_type: nil },
-
"third_party.bank_account_type" => { field: "bank_account_type", label: "Tipo de Cuenta", person_type: nil },
-
"third_party.bank_account_number" => { field: "bank_account_number", label: "Número de Cuenta", person_type: nil },
-
"third_party.tax_regime" => { field: "tax_regime", label: "Régimen Tributario", person_type: nil },
-
"third_party.industry" => { field: "industry", label: "Industria/Sector", person_type: nil },
-
"third_party.website" => { field: "website", label: "Sitio Web", person_type: nil }
-
}
-
-
required_fields = []
-
variables.each do |variable|
-
mapping_key = variable_mappings[variable]
-
next unless mapping_key&.start_with?("third_party.")
-
-
field_info = variable_to_field_map[mapping_key]
-
next unless field_info
-
-
# Avoid duplicates
-
next if required_fields.any? { |f| f[:field] == field_info[:field] }
-
-
required_fields << {
-
field: field_info[:field],
-
label: field_info[:label],
-
variable: variable,
-
person_type: field_info[:person_type],
-
required: true
-
}
-
end
-
-
required_fields
-
end
-
-
# Check if template uses third party variables
-
def uses_third_party_variables?
-
return false if variable_mappings.blank?
-
variable_mappings.values.any? { |v| v&.start_with?("third_party.") }
-
end
-
-
# Get suggested person_type based on required fields
-
def suggested_person_type
-
fields = required_third_party_fields
-
has_juridical = fields.any? { |f| f[:person_type] == "juridical" }
-
has_natural = fields.any? { |f| f[:person_type] == "natural" }
-
-
return "juridical" if has_juridical && !has_natural
-
return "natural" if has_natural && !has_juridical
-
nil # Both or neither - let user choose
-
end
-
-
class InvalidStateError < StandardError; end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class TemplateSignatory
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "template_signatories"
-
-
# Legacy signatory roles (kept for backward compatibility)
-
EMPLOYEE = "employee"
-
SUPERVISOR = "supervisor"
-
HR = "hr"
-
HR_MANAGER = "hr_manager"
-
LEGAL = "legal"
-
ADMIN = "admin"
-
CUSTOM = "custom"
-
-
ROLES = [EMPLOYEE, SUPERVISOR, HR, HR_MANAGER, LEGAL, ADMIN, CUSTOM].freeze
-
-
ROLE_LABELS = {
-
EMPLOYEE => "Empleado Solicitante",
-
SUPERVISOR => "Supervisor Directo",
-
HR => "Recursos Humanos",
-
HR_MANAGER => "Gerente de RR.HH.",
-
LEGAL => "Departamento Legal",
-
ADMIN => "Administrador",
-
CUSTOM => "Personalizado"
-
}.freeze
-
-
# Fields
-
field :role, type: String # Legacy field, use signatory_type_code for new entries
-
field :signatory_type_code, type: String # Reference to SignatoryType by code
-
field :label, type: String # Display label, e.g., "Firma del Empleado"
-
field :position, type: Integer, default: 0 # Order of signature
-
field :required, type: Boolean, default: true
-
field :placeholder_text, type: String, default: "Firma"
-
-
# Signature placement on PDF (coordinates relative to page)
-
field :page_number, type: Integer, default: 1 # 0 = last page
-
field :x_position, type: Float, default: 100.0
-
field :y_position, type: Float, default: 100.0
-
field :width, type: Float, default: 200.0
-
field :height, type: Float, default: 60.0
-
-
# Date position relative to signature
-
# right: fecha a la derecha (default, firma usa 75% ancho)
-
# below: fecha debajo (firma usa 100% ancho, 80% alto)
-
# above: fecha arriba (firma usa 100% ancho, 80% alto)
-
# none: sin fecha (firma usa 100% del espacio)
-
DATE_POSITIONS = %w[right below above none].freeze
-
field :date_position, type: String, default: "right"
-
-
# Display options for signature rendering
-
field :show_label, type: Boolean, default: true # Show label (e.g., "Representante Legal")
-
field :show_signer_name, type: Boolean, default: false # Show "Firmado por: [nombre]"
-
-
# For custom role - specific user or email
-
field :custom_user_id, type: BSON::ObjectId
-
field :custom_email, type: String
-
-
# Associations
-
belongs_to :template, class_name: "Templates::Template", inverse_of: :signatories
-
-
# Indexes
-
index({ template_id: 1, position: 1 })
-
index({ role: 1 })
-
-
# Validations
-
validates :label, presence: true, length: { maximum: 100 }
-
validate :valid_signatory_type
-
validates :position, presence: true, numericality: { greater_than_or_equal_to: 0 }
-
validates :x_position, :y_position, :width, :height,
-
numericality: { greater_than_or_equal_to: 0 }
-
validates :custom_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
-
validates :date_position, inclusion: { in: DATE_POSITIONS }, allow_blank: true
-
-
# Scopes
-
scope :required, -> { where(required: true) }
-
scope :optional, -> { where(required: false) }
-
scope :by_position, -> { order(position: :asc) }
-
-
# Callbacks
-
before_validation :set_default_label, on: :create
-
-
# Instance methods
-
def role_label
-
# Try to get label from SignatoryType first
-
if signatory_type_code.present?
-
signatory_type&.name || signatory_type_code
-
else
-
ROLE_LABELS[role] || role
-
end
-
end
-
-
def effective_code
-
signatory_type_code.presence || role
-
end
-
-
def signatory_type
-
return nil if signatory_type_code.blank?
-
-
@signatory_type ||= SignatoryType.find_by(code: signatory_type_code)
-
end
-
-
def custom?
-
effective_code == CUSTOM
-
end
-
-
def employee_signatory?
-
role == EMPLOYEE
-
end
-
-
# Find the appropriate user to sign based on role and context
-
def find_signatory_for(context)
-
# Use signatory_type_code if role is blank
-
effective_role = role.presence || signatory_type_code
-
-
case effective_role
-
when EMPLOYEE, "employee"
-
context[:employee]&.user
-
when SUPERVISOR, "supervisor"
-
context[:employee]&.supervisor&.user
-
when HR, "hr"
-
find_user_with_role("hr", context[:organization])
-
when HR_MANAGER, "hr_manager"
-
find_user_with_role("hr_manager", context[:organization]) || find_user_with_role("hr", context[:organization])
-
when LEGAL, "legal"
-
find_user_with_role("legal", context[:organization])
-
when "legal_representative"
-
find_user_with_role("legal_representative", context[:organization])
-
when "general_manager"
-
find_user_with_role("general_manager", context[:organization])
-
when "ceo"
-
find_user_with_role("ceo", context[:organization])
-
when "accountant"
-
find_user_with_role("accountant", context[:organization])
-
when "manager", "area_manager"
-
find_user_with_role("manager", context[:organization])
-
when ADMIN, "admin"
-
find_user_with_role("admin", context[:organization])
-
when CUSTOM, "custom"
-
find_custom_signatory
-
else
-
# Try to find by role name directly
-
find_user_with_role(effective_role, context[:organization])
-
end
-
end
-
-
# Signature box coordinates for PDF rendering
-
def signature_box
-
{
-
x: x_position,
-
y: y_position,
-
width: width,
-
height: height,
-
page: page_number,
-
date_position: date_position || "right",
-
show_label: show_label.nil? ? true : show_label,
-
show_signer_name: show_signer_name || false
-
}
-
end
-
-
private
-
-
def valid_signatory_type
-
# Must have either role or signatory_type_code
-
if role.blank? && signatory_type_code.blank?
-
errors.add(:base, "Debe especificar un tipo de firmante")
-
return
-
end
-
-
# If using legacy role, validate it
-
if role.present? && signatory_type_code.blank?
-
unless ROLES.include?(role)
-
errors.add(:role, "no es un rol válido")
-
end
-
end
-
-
# If using signatory_type_code, validate it exists
-
if signatory_type_code.present?
-
unless SignatoryType.exists?(code: signatory_type_code)
-
errors.add(:signatory_type_code, "no es un tipo de firmante válido")
-
end
-
end
-
end
-
-
def set_default_label
-
return if label.present?
-
-
if signatory_type_code.present?
-
self.label = signatory_type&.name || "Firma"
-
else
-
self.label = ROLE_LABELS[role] || "Firma"
-
end
-
end
-
-
# Generic method to find a user with a specific role in an organization
-
def find_user_with_role(role_name, organization)
-
return nil unless organization
-
-
role = Identity::Role.where(name: role_name).first
-
return nil unless role
-
-
Identity::User.where(
-
organization_id: organization.id,
-
:role_ids.in => [role.id],
-
active: true
-
).first
-
end
-
-
def find_custom_signatory
-
if custom_user_id.present?
-
Identity::User.where(id: custom_user_id, active: true).first
-
elsif custom_email.present?
-
Identity::User.where(email: custom_email, active: true).first
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class VariableMapping
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "variable_mappings"
-
-
# Categories for organizing mappings
-
CATEGORIES = {
-
"employee" => "Empleado",
-
"organization" => "Organización",
-
"request" => "Solicitud",
-
"third_party" => "Tercero",
-
"contract" => "Contrato",
-
"system" => "Sistema",
-
"custom" => "Personalizado"
-
}.freeze
-
-
# Data types for value resolution
-
DATA_TYPES = %w[string date number boolean email].freeze
-
-
# Fields
-
field :name, type: String # Display name, e.g., "Salario Mensual"
-
field :key, type: String # Unique key, e.g., "employee.monthly_salary"
-
field :category, type: String # Category for grouping
-
field :description, type: String # Help text
-
field :data_type, type: String, default: "string"
-
field :format_pattern, type: String # Optional format, e.g., "$%{value}" for currency
-
field :is_system, type: Boolean, default: false # System mappings can't be deleted
-
field :active, type: Boolean, default: true
-
field :position, type: Integer, default: 0
-
field :aliases, type: Array, default: [] # Alternative names that map to the same key
-
-
# For custom mappings that pull from specific model fields
-
field :source_model, type: String # e.g., "Hr::Employee"
-
field :source_field, type: String # e.g., "monthly_salary"
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization", optional: true
-
belongs_to :created_by, class_name: "Identity::User", optional: true
-
-
# Indexes
-
index({ organization_id: 1, active: 1 })
-
index({ key: 1 })
-
index({ name: 1, is_system: 1 }, { unique: true })
-
index({ category: 1 })
-
index({ is_system: 1 })
-
index({ position: 1 })
-
-
# Validations
-
validates :name, presence: true, length: { maximum: 100 }, uniqueness: { scope: :is_system }
-
validates :key, presence: true, length: { maximum: 100 }
-
validates :category, presence: true, inclusion: { in: CATEGORIES.keys }
-
validates :data_type, inclusion: { in: DATA_TYPES }
-
-
validate :key_format_valid
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :inactive, -> { where(active: false) }
-
scope :system_mappings, -> { where(is_system: true) }
-
scope :custom_mappings, -> { where(is_system: false) }
-
scope :by_category, ->(cat) { where(category: cat) }
-
scope :for_organization, ->(org) { where(:organization_id.in => [nil, org.id]) }
-
scope :ordered, -> { order(category: :asc, position: :asc, name: :asc) }
-
-
# Callbacks
-
before_validation :generate_key, on: :create, if: -> { key.blank? }
-
before_validation :normalize_name
-
-
# Instance methods
-
def system?
-
is_system
-
end
-
-
def custom?
-
!is_system
-
end
-
-
def category_label
-
CATEGORIES[category] || category
-
end
-
-
def activate!
-
update!(active: true)
-
end
-
-
def deactivate!
-
update!(active: false)
-
end
-
-
def toggle_active!
-
update!(active: !active)
-
end
-
-
# Add an alias to this mapping
-
def add_alias(alias_name)
-
normalized = VariableNormalizer.normalize(alias_name)
-
return false if normalized == name || aliases.include?(normalized)
-
-
self.aliases = (aliases + [normalized]).uniq
-
save!
-
end
-
-
# Remove an alias
-
def remove_alias(alias_name)
-
normalized = VariableNormalizer.normalize(alias_name)
-
return false unless aliases.include?(normalized)
-
-
self.aliases = aliases - [normalized]
-
save!
-
end
-
-
# Check if a name matches this mapping (name or any alias)
-
def matches_name?(search_name)
-
normalized_search = VariableNormalizer.comparison_key(search_name)
-
return true if VariableNormalizer.comparison_key(name) == normalized_search
-
-
aliases.any? { |a| VariableNormalizer.comparison_key(a) == normalized_search }
-
end
-
-
# All names (primary + aliases)
-
def all_names
-
[name] + (aliases || [])
-
end
-
-
# Resolve the value for this mapping given a context
-
def resolve_value(context)
-
return nil unless active?
-
-
if source_model.present? && source_field.present?
-
resolve_from_source(context)
-
else
-
resolve_from_path(context)
-
end
-
end
-
-
# Class methods
-
class << self
-
# Get all available mappings (system + org custom)
-
def available_for(organization)
-
active.for_organization(organization).ordered
-
end
-
-
# Convert to hash format for API
-
def to_mapping_hash(organization = nil)
-
mappings = organization ? available_for(organization) : active.ordered
-
mappings.each_with_object({}) do |mapping, hash|
-
hash[mapping.name] = mapping.key
-
end
-
end
-
-
# Grouped by category
-
def grouped_for(organization)
-
available_for(organization).group_by(&:category)
-
end
-
-
# Find mapping by name or alias (case/accent insensitive)
-
def find_by_name_or_alias(search_name, organization = nil)
-
mappings = organization ? available_for(organization) : active.ordered
-
normalized_search = VariableNormalizer.comparison_key(search_name)
-
-
mappings.find do |m|
-
m.matches_name?(search_name)
-
end
-
end
-
-
# Seed system mappings
-
def seed_system_mappings!
-
system_mappings_data.each do |data|
-
normalized_name = VariableNormalizer.normalize(data[:name])
-
-
# Use name as unique identifier (allows multiple names for same key)
-
mapping = where(name: normalized_name, is_system: true).first
-
if mapping
-
mapping.update!(data.merge(is_system: true, name: normalized_name))
-
else
-
create!(data.merge(is_system: true, name: normalized_name))
-
end
-
end
-
end
-
-
private
-
-
def system_mappings_data
-
[
-
# Employee mappings - Personal info
-
{ name: "Nombre Completo", key: "employee.full_name", category: "employee", description: "Nombre y apellido del empleado" },
-
{ name: "Nombre del Trabajador", key: "employee.full_name", category: "employee", description: "Nombre completo del trabajador" },
-
{ name: "Primer Nombre", key: "employee.first_name", category: "employee", description: "Primer nombre del empleado" },
-
{ name: "Apellido", key: "employee.last_name", category: "employee", description: "Apellido del empleado" },
-
{ name: "Numero de Empleado", key: "employee.employee_number", category: "employee", description: "Código único del empleado" },
-
{ name: "Cargo", key: "employee.job_title", category: "employee", description: "Cargo o posición del empleado" },
-
{ name: "Nombre del Cargo", key: "employee.job_title", category: "employee", description: "Cargo o posición" },
-
{ name: "Departamento", key: "employee.department", category: "employee", description: "Departamento donde trabaja" },
-
{ name: "Numero de Identificacion", key: "employee.identification_number", category: "employee", description: "Número de cédula o documento" },
-
{ name: "Cedula", key: "employee.identification_number", category: "employee", description: "Número de cédula" },
-
{ name: "Cc del Trabajador", key: "employee.identification_number", category: "employee", description: "Cédula de ciudadanía del trabajador" },
-
{ name: "Tipo de Identificacion", key: "employee.identification_type", category: "employee", description: "Tipo de documento (CC, CE, etc.)" },
-
{ name: "Email del Empleado", key: "employee.email", category: "employee", description: "Correo electrónico" },
-
{ name: "Fecha de Nacimiento", key: "employee.date_of_birth", category: "employee", data_type: "date", description: "Fecha de nacimiento" },
-
{ name: "Lugar de Nacimiento", key: "employee.place_of_birth", category: "employee", description: "Lugar de nacimiento" },
-
{ name: "Nacionalidad", key: "employee.nationality", category: "employee", description: "Nacionalidad del empleado" },
-
{ name: "Direccion del Trabajador", key: "employee.address", category: "employee", description: "Dirección del trabajador" },
-
{ name: "Telefono del Trabajador", key: "employee.phone", category: "employee", description: "Teléfono del trabajador" },
-
-
# Employee mappings - Contract & compensation
-
{ name: "Fecha de Ingreso", key: "employee.hire_date", category: "employee", data_type: "date", description: "Fecha de contratación" },
-
{ name: "Fecha de Contratacion", key: "employee.hire_date", category: "employee", data_type: "date", description: "Fecha de contratación (alias)" },
-
{ name: "Fecha de Inicio del Contrato", key: "employee.contract_start_date", category: "employee", data_type: "date", description: "Fecha de inicio del contrato (usa fecha de ingreso si no está definida)" },
-
{ name: "Fecha de Terminacion del Contrato", key: "employee.contract_end_date", category: "employee", data_type: "date", description: "Fecha de terminación del contrato" },
-
{ name: "Fecha de Terminacion", key: "employee.contract_end_date", category: "employee", data_type: "date", description: "Fecha de terminación (alias)" },
-
{ name: "Tipo de Contrato", key: "employee.contract_type", category: "employee", description: "Tipo de contrato laboral" },
-
{ name: "Termino de Duracion", key: "employee.contract_duration", category: "employee", description: "Término de duración del contrato" },
-
{ name: "Dias de Periodo de Prueba", key: "employee.trial_period_days", category: "employee", data_type: "number", description: "Días del periodo de prueba" },
-
{ name: "Anos de Servicio", key: "employee.years_of_service", category: "employee", data_type: "number", description: "Antigüedad en años" },
-
{ name: "Anos de Servicio Texto", key: "employee.years_of_service_text", category: "employee", description: "Antigüedad en texto" },
-
{ name: "Salario", key: "employee.salary", category: "employee", data_type: "number", description: "Salario mensual del empleado" },
-
{ name: "Salario en Letras", key: "employee.salary_text", category: "employee", description: "Salario en palabras" },
-
{ name: "Auxilio de Transporte", key: "employee.transport_allowance", category: "employee", data_type: "number", description: "Auxilio de transporte mensual" },
-
{ name: "Auxilio de Transporte en Letras", key: "employee.transport_allowance_text", category: "employee", description: "Auxilio de transporte en palabras" },
-
{ name: "Auxilio de Alimentacion", key: "employee.food_allowance", category: "employee", data_type: "number", description: "Auxilio de alimentación mensual" },
-
{ name: "Auxilio de Alimentacion en Letras", key: "employee.food_allowance_text", category: "employee", description: "Auxilio de alimentación en palabras" },
-
{ name: "Compensacion Total", key: "employee.total_compensation", category: "employee", data_type: "number", description: "Total salario + auxilios" },
-
{ name: "Compensacion Total en Letras", key: "employee.total_compensation_text", category: "employee", description: "Total salario + auxilios en palabras" },
-
-
# Organization mappings
-
{ name: "Nombre de Empresa", key: "organization.name", category: "organization", description: "Razón social de la empresa" },
-
{ name: "Nit", key: "organization.tax_id", category: "organization", description: "Identificación tributaria" },
-
{ name: "Direccion de la Empresa", key: "organization.address", category: "organization", description: "Dirección de la empresa" },
-
{ name: "Ciudad", key: "organization.city", category: "organization", description: "Ciudad de la empresa" },
-
{ name: "Telefono de la Empresa", key: "organization.phone", category: "organization", description: "Teléfono de contacto" },
-
-
# System mappings
-
{ name: "Fecha Actual", key: "system.current_date", category: "system", data_type: "date", description: "Fecha del día de generación" },
-
{ name: "Dia/Mes/Ano", key: "system.current_date", category: "system", data_type: "date", description: "Fecha actual en formato día/mes/año" },
-
{ name: "Fecha Actual Texto", key: "system.current_date_text", category: "system", description: "Fecha en formato largo" },
-
{ name: "Ano Actual", key: "system.current_year", category: "system", description: "Año de generación" },
-
{ name: "Mes Actual", key: "system.current_month", category: "system", description: "Mes de generación" },
-
-
# Request mappings (for certifications/vacations)
-
{ name: "Numero de Solicitud", key: "request.request_number", category: "request", description: "Número único de la solicitud" },
-
{ name: "Tipo de Certificacion", key: "request.certification_type", category: "request", description: "Tipo de certificación solicitada" },
-
{ name: "Proposito", key: "request.purpose", category: "request", description: "Propósito de la solicitud" },
-
{ name: "Fecha de Inicio de Vacaciones", key: "request.start_date", category: "request", data_type: "date", description: "Fecha de inicio de vacaciones" },
-
{ name: "Fecha de Fin de Vacaciones", key: "request.end_date", category: "request", data_type: "date", description: "Fecha de fin de vacaciones" },
-
{ name: "Dias Solicitados", key: "request.days_requested", category: "request", data_type: "number", description: "Cantidad de días solicitados" },
-
-
# Custom mappings - Text conversions (valores tomados del empleado y convertidos a texto)
-
{ name: "Salario Letras y Pesos", key: "custom.salario_letras_y_pesos", category: "custom", description: "Salario en palabras (toma de employee.salary)" },
-
{ name: "Auxilio Alimentacion en Letras y Pesos", key: "custom.auxilio_alimentacion_en_letras_y_pesos", category: "custom", description: "Auxilio alimentación en palabras (toma de employee.food_allowance)" },
-
{ name: "Auxilio Transporte en Letras y Pesos", key: "custom.auxilio_transporte_en_letras_y_pesos", category: "custom", description: "Auxilio transporte en palabras (toma de employee.transport_allowance)" },
-
{ name: "Compensacion Total en Letras", key: "custom.compensacion_total_en_letras", category: "custom", description: "Compensación total en palabras (salario + auxilios)" },
-
-
# Third Party mappings (Terceros - Módulo Legal)
-
{ name: "Nombre del Tercero", key: "third_party.display_name", category: "third_party", description: "Nombre o razón social del tercero" },
-
{ name: "Razon Social", key: "third_party.business_name", category: "third_party", description: "Razón social de persona jurídica" },
-
{ name: "Nombre Comercial", key: "third_party.trade_name", category: "third_party", description: "Nombre comercial del tercero" },
-
{ name: "Codigo del Tercero", key: "third_party.code", category: "third_party", description: "Código único del tercero (TER-YYYY-NNNNN)" },
-
{ name: "Identificacion del Tercero", key: "third_party.identification_number", category: "third_party", description: "Número de identificación (NIT, CC, etc.)" },
-
{ name: "Tipo de Identificacion del Tercero", key: "third_party.identification_type", category: "third_party", description: "Tipo de documento del tercero" },
-
{ name: "Identificacion Completa del Tercero", key: "third_party.full_identification", category: "third_party", description: "Tipo y número de identificación" },
-
{ name: "Tipo de Tercero", key: "third_party.third_party_type", category: "third_party", description: "Proveedor, cliente, contratista, etc." },
-
{ name: "Tipo de Persona", key: "third_party.person_type", category: "third_party", description: "Natural o Jurídica" },
-
{ name: "Email del Tercero", key: "third_party.email", category: "third_party", description: "Correo electrónico del tercero" },
-
{ name: "Telefono del Tercero", key: "third_party.phone", category: "third_party", description: "Teléfono de contacto" },
-
{ name: "Direccion del Tercero", key: "third_party.address", category: "third_party", description: "Dirección del tercero" },
-
{ name: "Ciudad del Tercero", key: "third_party.city", category: "third_party", description: "Ciudad de ubicación" },
-
{ name: "Pais del Tercero", key: "third_party.country", category: "third_party", description: "País de ubicación" },
-
{ name: "Representante Legal", key: "third_party.legal_rep_name", category: "third_party", description: "Nombre del representante legal" },
-
{ name: "Cedula Representante Legal", key: "third_party.legal_rep_id", category: "third_party", description: "Cédula del representante legal" },
-
{ name: "Email Representante Legal", key: "third_party.legal_rep_email", category: "third_party", description: "Email del representante legal" },
-
{ name: "Banco del Tercero", key: "third_party.bank_name", category: "third_party", description: "Nombre del banco" },
-
{ name: "Tipo de Cuenta Bancaria", key: "third_party.bank_account_type", category: "third_party", description: "Ahorros o Corriente" },
-
{ name: "Numero de Cuenta Bancaria", key: "third_party.bank_account_number", category: "third_party", description: "Número de cuenta" },
-
{ name: "Industria del Tercero", key: "third_party.industry", category: "third_party", description: "Sector o industria" },
-
-
# Contract mappings (Contratos - Módulo Legal)
-
{ name: "Numero de Contrato", key: "contract.contract_number", category: "contract", description: "Número único del contrato (CON-YYYY-NNNNN)" },
-
{ name: "Titulo del Contrato", key: "contract.title", category: "contract", description: "Título o nombre del contrato" },
-
{ name: "Descripcion del Contrato", key: "contract.description", category: "contract", description: "Descripción del objeto del contrato" },
-
{ name: "Tipo de Contrato Comercial", key: "contract.contract_type", category: "contract", description: "Servicios, compraventa, NDA, etc." },
-
{ name: "Estado del Contrato", key: "contract.status", category: "contract", description: "Estado actual del contrato" },
-
{ name: "Monto del Contrato", key: "contract.amount", category: "contract", data_type: "number", description: "Valor monetario del contrato" },
-
{ name: "Monto en Letras", key: "contract.amount_text", category: "contract", description: "Monto del contrato en palabras" },
-
{ name: "Moneda del Contrato", key: "contract.currency", category: "contract", description: "Moneda (COP, USD, EUR)" },
-
{ name: "Fecha de Inicio Vigencia", key: "contract.start_date", category: "contract", data_type: "date", description: "Fecha de inicio de vigencia del contrato comercial" },
-
{ name: "Fecha de Inicio en Texto", key: "contract.start_date_text", category: "contract", description: "Fecha de inicio en formato largo" },
-
{ name: "Fecha de Fin del Contrato", key: "contract.end_date", category: "contract", data_type: "date", description: "Fecha de terminación del contrato" },
-
{ name: "Fecha de Fin en Texto", key: "contract.end_date_text", category: "contract", description: "Fecha de fin en formato largo" },
-
{ name: "Duracion del Contrato en Dias", key: "contract.duration_days", category: "contract", data_type: "number", description: "Días de duración" },
-
{ name: "Duracion del Contrato", key: "contract.duration_text", category: "contract", description: "Duración en texto (meses, años)" },
-
{ name: "Condiciones de Pago", key: "contract.payment_terms", category: "contract", description: "Términos de pago acordados" },
-
{ name: "Frecuencia de Pago", key: "contract.payment_frequency", category: "contract", description: "Mensual, quincenal, único, etc." },
-
{ name: "Nivel de Aprobacion", key: "contract.approval_level", category: "contract", description: "Nivel requerido de aprobación" },
-
{ name: "Fecha de Aprobacion", key: "contract.approved_at", category: "contract", data_type: "date", description: "Fecha en que fue aprobado" },
-
{ name: "Fecha de Aprobacion en Texto", key: "contract.approved_at_text", category: "contract", description: "Fecha de aprobación en formato largo" }
-
]
-
end
-
end
-
-
private
-
-
def normalize_name
-
return if name.blank?
-
-
self.name = VariableNormalizer.normalize(name)
-
end
-
-
def generate_key
-
base = VariableNormalizer.to_key(name)
-
self.key = "custom.#{base}"
-
end
-
-
def key_format_valid
-
return if key.blank?
-
-
unless key.match?(/\A[a-z_]+\.[a-z_]+\z/)
-
errors.add(:key, "debe tener formato 'categoria.campo' (solo letras minúsculas y guiones bajos)")
-
end
-
end
-
-
def resolve_from_source(context)
-
return nil unless source_model && source_field
-
-
# Get the source object from context
-
source = case source_model
-
when "Hr::Employee"
-
context[:employee]
-
when "Identity::Organization"
-
context[:organization]
-
else
-
nil
-
end
-
-
return nil unless source
-
-
source.try(source_field)
-
end
-
-
def resolve_from_path(context)
-
# Delegate to VariableResolverService
-
VariableResolverService.new(context).resolve(key)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Workflow
-
# Defines a workflow template that can be instantiated
-
# Contains the state machine configuration and step definitions
-
#
-
# Example: Contract Approval Workflow
-
# states: draft -> legal_review -> approved/rejected
-
#
-
class WorkflowDefinition
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "workflow_definitions"
-
-
# Fields
-
field :name, type: String
-
field :description, type: String
-
field :version, type: Integer, default: 1
-
field :active, type: Boolean, default: true
-
field :document_type, type: String # Type of document this workflow applies to
-
-
# State machine configuration
-
field :initial_state, type: String
-
field :states, type: Array, default: [] # Array of state names
-
field :final_states, type: Array, default: [] # Terminal states
-
field :transitions, type: Array, default: [] # Allowed transitions
-
-
# Step definitions with role assignments and SLAs
-
# Format: { state: "legal_review", assigned_role: "legal", sla_hours: 48, ... }
-
field :steps, type: Hash, default: {}
-
-
# Default SLA in hours if not specified per step
-
field :default_sla_hours, type: Integer, default: 24
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ name: 1, version: 1 }, { unique: true })
-
index({ document_type: 1 })
-
index({ active: 1 })
-
-
# Associations
-
belongs_to :organization, class_name: "Identity::Organization", optional: true
-
has_many :instances, class_name: "Workflow::WorkflowInstance", inverse_of: :definition
-
-
# Validations
-
validates :name, presence: true
-
validates :initial_state, presence: true
-
validates :states, presence: true
-
validate :initial_state_in_states
-
validate :final_states_in_states
-
validate :transitions_valid
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :for_document_type, ->(type) { where(document_type: type) }
-
scope :latest_versions, -> { where(active: true).order(version: :desc) }
-
-
# Check if a transition is allowed
-
def transition_allowed?(from_state, to_state)
-
transitions.any? do |t|
-
t["from"] == from_state && t["to"] == to_state
-
end
-
end
-
-
# Get available transitions from a state
-
def available_transitions(from_state)
-
transitions.select { |t| t["from"] == from_state }.pluck("to")
-
end
-
-
# Get step configuration for a state
-
def step_for(state)
-
steps[state] || {}
-
end
-
-
# Get assigned role for a state
-
def assigned_role_for(state)
-
step_for(state)["assigned_role"]
-
end
-
-
# Get SLA hours for a state
-
def sla_hours_for(state)
-
step_for(state)["sla_hours"] || default_sla_hours
-
end
-
-
# Check if state is final
-
def final_state?(state)
-
final_states.include?(state)
-
end
-
-
# Create a new instance of this workflow
-
def create_instance!(document:, initiated_by:)
-
WorkflowInstance.create!(
-
definition: self,
-
document: document,
-
organization: organization || document.organization,
-
current_state: initial_state,
-
initiated_by: initiated_by,
-
started_at: Time.current
-
)
-
end
-
-
# Create a new version of this definition
-
def create_new_version!
-
new_def = dup
-
new_def.uuid = nil # Clear UUID so a new one is generated
-
new_def.version = version + 1
-
new_def.save!
-
-
# Deactivate old version
-
update!(active: false)
-
-
new_def
-
end
-
-
private
-
-
def initial_state_in_states
-
return if states.include?(initial_state)
-
-
errors.add(:initial_state, "must be one of the defined states")
-
end
-
-
def final_states_in_states
-
invalid = final_states - states
-
return if invalid.empty?
-
-
errors.add(:final_states, "contains invalid states: #{invalid.join(", ")}")
-
end
-
-
def transitions_valid
-
transitions.each do |t|
-
unless t["from"].present? && t["to"].present?
-
errors.add(:transitions, "must have 'from' and 'to' states")
-
next
-
end
-
-
unless states.include?(t["from"]) && states.include?(t["to"])
-
errors.add(:transitions, "contains invalid states in transition: #{t["from"]} -> #{t["to"]}")
-
end
-
end
-
end
-
-
class << self
-
# Find the latest active version of a workflow by name
-
def find_latest(name)
-
active.where(name: name).order(version: :desc).first
-
end
-
-
# Seed the contract approval workflow
-
# rubocop:disable Metrics/MethodLength, Metrics/BlockLength
-
def seed_contract_approval!
-
find_or_create_by!(name: "contract_approval", version: 1) do |w|
-
w.description = "Standard contract approval workflow with legal review"
-
w.document_type = "contract"
-
w.initial_state = "draft"
-
w.states = ["draft", "legal_review", "approved", "rejected"]
-
w.final_states = ["approved", "rejected"]
-
w.transitions = [
-
{ "from" => "draft", "to" => "legal_review", "action" => "submit_for_review" },
-
{ "from" => "legal_review", "to" => "approved", "action" => "approve" },
-
{ "from" => "legal_review", "to" => "rejected", "action" => "reject" },
-
{ "from" => "legal_review", "to" => "draft", "action" => "request_changes" },
-
{ "from" => "rejected", "to" => "draft", "action" => "revise" }
-
]
-
w.steps = {
-
"draft" => {
-
"assigned_role" => "employee",
-
"sla_hours" => nil, # No SLA for draft
-
"description" => "Initial draft creation"
-
},
-
"legal_review" => {
-
"assigned_role" => "legal",
-
"sla_hours" => 48,
-
"description" => "Legal team review and approval"
-
},
-
"approved" => {
-
"assigned_role" => nil,
-
"sla_hours" => nil,
-
"description" => "Contract approved"
-
},
-
"rejected" => {
-
"assigned_role" => nil,
-
"sla_hours" => nil,
-
"description" => "Contract rejected"
-
}
-
}
-
w.default_sla_hours = 24
-
end
-
end
-
# rubocop:enable Metrics/MethodLength, Metrics/BlockLength
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Workflow
-
# Represents a running instance of a workflow
-
# Tracks the current state, history, and progress through the workflow
-
#
-
# Example: A specific contract going through the approval workflow
-
#
-
# rubocop:disable Metrics/ClassLength
-
class WorkflowInstance
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "workflow_instances"
-
-
# Status constants
-
STATUS_ACTIVE = "active"
-
STATUS_COMPLETED = "completed"
-
STATUS_CANCELLED = "cancelled"
-
STATUS_SUSPENDED = "suspended"
-
-
STATUSES = [STATUS_ACTIVE, STATUS_COMPLETED, STATUS_CANCELLED, STATUS_SUSPENDED].freeze
-
-
# Fields
-
field :current_state, type: String
-
field :status, type: String, default: STATUS_ACTIVE
-
field :started_at, type: Time
-
field :completed_at, type: Time
-
field :cancelled_at, type: Time
-
field :cancellation_reason, type: String
-
-
# State history for audit trail
-
# Format: [{ from: "draft", to: "review", action: "submit", actor_id: "...", at: Time, comment: "..." }]
-
field :state_history, type: Array, default: []
-
-
# Custom data associated with this instance
-
field :context_data, type: Hash, default: {}
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ status: 1 })
-
index({ current_state: 1 })
-
index({ organization_id: 1, status: 1 })
-
index({ document_id: 1 })
-
index({ started_at: -1 })
-
-
# Associations
-
belongs_to :definition, class_name: "Workflow::WorkflowDefinition", inverse_of: :instances
-
belongs_to :document, class_name: "Content::Document", optional: true
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :initiated_by, class_name: "Identity::User"
-
has_many :tasks, class_name: "Workflow::WorkflowTask", inverse_of: :instance, dependent: :destroy
-
-
# Validations
-
validates :current_state, presence: true
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validate :current_state_valid
-
-
# Scopes
-
scope :active, -> { where(status: STATUS_ACTIVE) }
-
scope :completed, -> { where(status: STATUS_COMPLETED) }
-
scope :cancelled, -> { where(status: STATUS_CANCELLED) }
-
scope :suspended, -> { where(status: STATUS_SUSPENDED) }
-
scope :for_document, ->(doc) { where(document_id: doc.is_a?(BSON::ObjectId) ? doc : doc.id) }
-
scope :in_state, ->(state) { where(current_state: state) }
-
-
# Callbacks
-
after_create :create_initial_task
-
after_create :record_workflow_started
-
-
# Check if workflow is active
-
def active?
-
status == STATUS_ACTIVE
-
end
-
-
# Check if workflow is completed
-
def completed?
-
status == STATUS_COMPLETED
-
end
-
-
# Check if workflow is in a final state
-
def in_final_state?
-
definition.final_state?(current_state)
-
end
-
-
# Get available transitions from current state
-
def available_transitions
-
definition.available_transitions(current_state)
-
end
-
-
# Check if a transition is allowed
-
def can_transition_to?(to_state)
-
return false unless active?
-
-
definition.transition_allowed?(current_state, to_state)
-
end
-
-
# Perform a state transition
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
-
def transition_to!(to_state, actor:, action: nil, comment: nil)
-
raise WorkflowError, "Workflow is not active" unless active?
-
raise WorkflowError, "Transition not allowed: #{current_state} -> #{to_state}" unless can_transition_to?(to_state)
-
-
from_state = current_state
-
-
# Complete the task for the current (from) state BEFORE changing state
-
complete_task_for_state!(from_state, actor, comment)
-
-
# Record transition in history
-
state_history << {
-
"from" => from_state,
-
"to" => to_state,
-
"action" => action || find_action_for_transition(from_state, to_state),
-
"actor_id" => actor.id.to_s,
-
"actor_name" => actor.full_name,
-
"at" => Time.current.iso8601,
-
"comment" => comment
-
}.compact
-
-
# Update current state
-
self.current_state = to_state
-
-
# Check if we've reached a final state
-
if definition.final_state?(to_state)
-
self.status = STATUS_COMPLETED
-
self.completed_at = Time.current
-
else
-
# Create task for next state
-
create_task_for_state!(to_state)
-
end
-
-
save!
-
-
# Fire notification job
-
notify_transition(from_state, to_state, actor)
-
-
self
-
end
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
-
-
# Cancel the workflow
-
def cancel!(actor:, reason: nil)
-
raise WorkflowError, "Workflow is not active" unless active?
-
-
self.status = STATUS_CANCELLED
-
self.cancelled_at = Time.current
-
self.cancellation_reason = reason
-
-
state_history << {
-
"from" => current_state,
-
"to" => "cancelled",
-
"action" => "cancel",
-
"actor_id" => actor.id.to_s,
-
"actor_name" => actor.full_name,
-
"at" => Time.current.iso8601,
-
"comment" => reason
-
}.compact
-
-
# Cancel any active tasks (pending or in_progress)
-
# rubocop:disable Rails/FindEach
-
Workflow::WorkflowTask.active.where(instance_id: id).each { |task| task.cancel!(actor) }
-
# rubocop:enable Rails/FindEach
-
-
save!
-
-
notify_cancellation(actor, reason)
-
-
self
-
end
-
-
# Suspend the workflow
-
def suspend!(actor:, reason: nil)
-
raise WorkflowError, "Workflow is not active" unless active?
-
-
self.status = STATUS_SUSPENDED
-
-
state_history << {
-
"from" => current_state,
-
"to" => current_state,
-
"action" => "suspend",
-
"actor_id" => actor.id.to_s,
-
"actor_name" => actor.full_name,
-
"at" => Time.current.iso8601,
-
"comment" => reason
-
}.compact
-
-
save!
-
self
-
end
-
-
# Resume a suspended workflow
-
def resume!(actor:)
-
raise WorkflowError, "Workflow is not suspended" unless status == STATUS_SUSPENDED
-
-
self.status = STATUS_ACTIVE
-
-
state_history << {
-
"from" => current_state,
-
"to" => current_state,
-
"action" => "resume",
-
"actor_id" => actor.id.to_s,
-
"actor_name" => actor.full_name,
-
"at" => Time.current.iso8601
-
}
-
-
save!
-
self
-
end
-
-
# Get the current active task (pending or in progress)
-
def current_task
-
tasks.active.where(state: current_state).first
-
end
-
-
# Get step configuration for current state
-
def current_step
-
definition.step_for(current_state)
-
end
-
-
# Get assigned role for current state
-
def current_assigned_role
-
definition.assigned_role_for(current_state)
-
end
-
-
# Duration of the workflow so far
-
def duration
-
end_time = completed_at || cancelled_at || Time.current
-
end_time - started_at
-
end
-
-
# Duration in a specific state
-
def time_in_state(state)
-
entries = state_history.select { |h| h["to"] == state }
-
exits = state_history.select { |h| h["from"] == state }
-
-
total = 0
-
entries.each_with_index do |entry, i|
-
exit_record = exits[i]
-
entry_time = Time.zone.parse(entry["at"])
-
exit_time = exit_record ? Time.zone.parse(exit_record["at"]) : Time.current
-
total += (exit_time - entry_time)
-
end
-
-
total
-
end
-
-
private
-
-
def current_state_valid
-
return if definition.blank?
-
return if definition.states.include?(current_state)
-
-
errors.add(:current_state, "is not a valid state for this workflow")
-
end
-
-
def create_initial_task
-
create_task_for_state!(current_state)
-
end
-
-
def create_task_for_state!(state)
-
step = definition.step_for(state)
-
sla_hours = definition.sla_hours_for(state)
-
-
tasks.create!(
-
state: state,
-
assigned_role: step["assigned_role"],
-
organization: organization,
-
due_at: sla_hours ? Time.current + sla_hours.hours : nil,
-
sla_hours: sla_hours
-
)
-
end
-
-
def complete_task_for_state!(state, actor, comment)
-
task = tasks.active.where(state: state).first
-
task&.complete!(actor, comment)
-
end
-
-
def find_action_for_transition(from, to)
-
transition = definition.transitions.find { |t| t["from"] == from && t["to"] == to }
-
transition&.dig("action")
-
end
-
-
def record_workflow_started
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:workflow],
-
action: "workflow_started",
-
target: self,
-
actor: initiated_by,
-
metadata: {
-
workflow_name: definition.name,
-
initial_state: current_state,
-
document_id: document_id&.to_s
-
},
-
tags: ["workflow", "started"]
-
)
-
end
-
-
def notify_transition(from_state, to_state, actor)
-
WorkflowNotificationJob.perform_later(
-
"transition",
-
id.to_s,
-
from_state: from_state,
-
to_state: to_state,
-
actor_id: actor.id.to_s
-
)
-
end
-
-
def notify_cancellation(actor, reason)
-
WorkflowNotificationJob.perform_later(
-
"cancelled",
-
id.to_s,
-
actor_id: actor.id.to_s,
-
reason: reason
-
)
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
-
# Custom workflow error
-
class WorkflowError < StandardError; end
-
end
-
# frozen_string_literal: true
-
-
module Workflow
-
# Represents a task within a workflow instance
-
# Tracks assignment, SLA, and completion of individual workflow steps
-
#
-
# Tasks are created when a workflow enters a new state and must be
-
# completed to advance the workflow
-
#
-
# rubocop:disable Metrics/ClassLength
-
class WorkflowTask
-
include Mongoid::Document
-
include Mongoid::Timestamps
-
include UuidIdentifiable
-
-
store_in collection: "workflow_tasks"
-
-
# Status constants
-
STATUS_PENDING = "pending"
-
STATUS_IN_PROGRESS = "in_progress"
-
STATUS_COMPLETED = "completed"
-
STATUS_CANCELLED = "cancelled"
-
STATUS_OVERDUE = "overdue"
-
-
STATUSES = [STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_COMPLETED, STATUS_CANCELLED, STATUS_OVERDUE].freeze
-
-
# Fields
-
field :state, type: String # The workflow state this task is for
-
field :status, type: String, default: STATUS_PENDING
-
field :assigned_role, type: String # Role that can complete this task
-
field :sla_hours, type: Integer
-
field :due_at, type: Time
-
field :started_at, type: Time
-
field :completed_at, type: Time
-
field :cancelled_at, type: Time
-
field :completion_comment, type: String
-
field :priority, type: Integer, default: 0 # Higher = more urgent
-
-
# Escalation tracking
-
field :escalation_level, type: Integer, default: 0
-
field :last_escalated_at, type: Time
-
field :escalation_history, type: Array, default: []
-
-
# Indexes
-
index({ uuid: 1 }, { unique: true })
-
index({ status: 1 })
-
index({ assigned_role: 1 })
-
index({ due_at: 1 })
-
index({ organization_id: 1, status: 1 })
-
index({ instance_id: 1, state: 1 })
-
index({ assignee_id: 1, status: 1 })
-
-
# Associations
-
belongs_to :instance, class_name: "Workflow::WorkflowInstance", inverse_of: :tasks
-
belongs_to :organization, class_name: "Identity::Organization"
-
belongs_to :assignee, class_name: "Identity::User", optional: true
-
belongs_to :completed_by, class_name: "Identity::User", optional: true
-
-
# Validations
-
validates :state, presence: true
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
-
# Scopes
-
scope :pending, -> { where(status: STATUS_PENDING) }
-
scope :in_progress, -> { where(status: STATUS_IN_PROGRESS) }
-
scope :completed, -> { where(status: STATUS_COMPLETED) }
-
scope :active, -> { where(:status.in => [STATUS_PENDING, STATUS_IN_PROGRESS]) }
-
scope :overdue, -> { where(:due_at.lt => Time.current, :status.in => [STATUS_PENDING, STATUS_IN_PROGRESS]) }
-
scope :due_soon, ->(hours = 4) { where(:due_at.lte => Time.current + hours.hours, :due_at.gt => Time.current) }
-
scope :for_role, ->(role) { where(assigned_role: role) }
-
scope :for_user, ->(user) { where(assignee_id: user.id) }
-
scope :by_priority, -> { order(priority: :desc, due_at: :asc) }
-
-
# Callbacks
-
after_create :schedule_sla_check
-
after_create :notify_assigned_role
-
-
# Check if task is pending
-
def pending?
-
status == STATUS_PENDING
-
end
-
-
# Check if task is in progress
-
def in_progress?
-
status == STATUS_IN_PROGRESS
-
end
-
-
# Check if task is completed
-
def completed?
-
status == STATUS_COMPLETED
-
end
-
-
# Check if task is overdue
-
def overdue?
-
return false if due_at.blank?
-
return false if completed? || status == STATUS_CANCELLED
-
-
Time.current > due_at
-
end
-
-
# Time remaining until due
-
def time_remaining
-
return nil if due_at.blank?
-
return 0 if overdue?
-
-
due_at - Time.current
-
end
-
-
# Time remaining as human readable
-
# rubocop:disable Metrics/PerceivedComplexity
-
def time_remaining_text
-
remaining = time_remaining
-
return "Overdue" if remaining.nil? || remaining <= 0
-
-
hours = (remaining / 1.hour).floor
-
if hours >= 24
-
days = (hours / 24).floor
-
"#{days} day#{"s" unless days == 1}"
-
elsif hours >= 1
-
"#{hours} hour#{"s" unless hours == 1}"
-
else
-
minutes = (remaining / 1.minute).floor
-
"#{minutes} minute#{"s" unless minutes == 1}"
-
end
-
end
-
# rubocop:enable Metrics/PerceivedComplexity
-
-
# Claim the task for a specific user
-
def claim!(user)
-
raise WorkflowError, "Task is not pending" unless pending?
-
raise WorkflowError, "User does not have required role" unless user_has_role?(user)
-
-
self.assignee = user
-
self.status = STATUS_IN_PROGRESS
-
self.started_at = Time.current
-
save!
-
-
record_audit_event("task_claimed", user)
-
-
self
-
end
-
-
# Release a claimed task back to the pool
-
def release!(user)
-
raise WorkflowError, "Task is not in progress" unless in_progress?
-
raise WorkflowError, "Only assignee can release task" unless assignee_id == user.id
-
-
self.assignee = nil
-
self.status = STATUS_PENDING
-
self.started_at = nil
-
save!
-
-
record_audit_event("task_released", user)
-
-
self
-
end
-
-
# Complete the task
-
def complete!(actor, comment = nil)
-
raise WorkflowError, "Task cannot be completed" unless can_complete?
-
-
self.status = STATUS_COMPLETED
-
self.completed_at = Time.current
-
self.completed_by = actor
-
self.completion_comment = comment
-
save!
-
-
record_audit_event("task_completed", actor)
-
-
self
-
end
-
-
# Cancel the task
-
def cancel!(actor)
-
return if status == STATUS_CANCELLED
-
-
self.status = STATUS_CANCELLED
-
self.cancelled_at = Time.current
-
save!
-
-
record_audit_event("task_cancelled", actor)
-
-
self
-
end
-
-
# Check if task can be completed
-
def can_complete?
-
pending? || in_progress?
-
end
-
-
# Check if user can work on this task
-
def user_can_work?(user)
-
return false unless can_complete?
-
return true if assignee_id == user.id
-
-
pending? && user_has_role?(user)
-
end
-
-
# Escalate the task
-
def escalate!(reason: nil)
-
self.escalation_level += 1
-
self.last_escalated_at = Time.current
-
self.priority += 10
-
-
escalation_history << {
-
"level" => escalation_level,
-
"at" => Time.current.iso8601,
-
"reason" => reason
-
}.compact
-
-
save!
-
-
WorkflowNotificationJob.perform_later(
-
"task_escalated",
-
id.to_s,
-
escalation_level: escalation_level,
-
reason: reason
-
)
-
-
self
-
end
-
-
# Get users who can work on this task
-
def eligible_users
-
return Identity::User.none if assigned_role.blank?
-
-
organization.users.joins(:roles).where(
-
identity_roles: { name: assigned_role }
-
)
-
end
-
-
# Duration of the task (started to completed or now)
-
def duration
-
return nil unless started_at
-
-
end_time = completed_at || Time.current
-
end_time - started_at
-
end
-
-
# Check SLA compliance
-
def sla_compliant?
-
return true if due_at.blank?
-
-
if completed?
-
completed_at <= due_at
-
else
-
Time.current <= due_at
-
end
-
end
-
-
private
-
-
def user_has_role?(user)
-
return true if assigned_role.blank?
-
-
user.roles.exists?(name: assigned_role)
-
end
-
-
# rubocop:disable Metrics/AbcSize, Naming/VariableNumber
-
def schedule_sla_check
-
return if due_at.blank?
-
-
# Schedule a job to check SLA at the due time
-
SlaCheckJob.set(wait_until: due_at).perform_later(id.to_s)
-
-
# Also schedule warning notifications at 75% and 50% of SLA
-
return unless sla_hours && sla_hours > 2
-
-
warning_time_75 = created_at + (sla_hours * 0.75).hours
-
warning_time_50 = created_at + (sla_hours * 0.5).hours
-
-
SlaWarningJob.set(wait_until: warning_time_75).perform_later(id.to_s, 75)
-
SlaWarningJob.set(wait_until: warning_time_50).perform_later(id.to_s, 50) if sla_hours > 8
-
end
-
# rubocop:enable Metrics/AbcSize, Naming/VariableNumber
-
-
def notify_assigned_role
-
WorkflowNotificationJob.perform_later(
-
"task_created",
-
id.to_s,
-
assigned_role: assigned_role,
-
state: state
-
)
-
end
-
-
def record_audit_event(action, actor)
-
Audit::AuditEvent.log(
-
event_type: Audit::AuditEvent::TYPES[:workflow],
-
action: action,
-
target: self,
-
actor: actor,
-
metadata: {
-
workflow_instance_id: instance_id.to_s,
-
state: state,
-
assigned_role: assigned_role
-
},
-
tags: ["workflow", "task", action]
-
)
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
class ApplicationPolicy
-
attr_reader :user, :record
-
-
def initialize(user, record)
-
@user = user
-
@record = record
-
end
-
-
def index?
-
false
-
end
-
-
def show?
-
false
-
end
-
-
def create?
-
false
-
end
-
-
def new?
-
create?
-
end
-
-
def update?
-
false
-
end
-
-
def edit?
-
update?
-
end
-
-
def destroy?
-
false
-
end
-
-
class Scope
-
attr_reader :user, :scope
-
-
def initialize(user, scope)
-
@user = user
-
@scope = scope
-
end
-
-
def resolve
-
raise NotImplementedError, "You must define #resolve in #{self.class}"
-
end
-
-
private
-
-
def admin?
-
user&.admin?
-
end
-
-
def super_admin?
-
user&.super_admin?
-
end
-
end
-
-
private
-
-
def admin?
-
user&.admin?
-
end
-
-
def super_admin?
-
user&.super_admin?
-
end
-
-
def owner?
-
return false unless record.respond_to?(:created_by_id)
-
-
record.created_by_id == user&.id
-
end
-
-
def same_organization?
-
return false unless user&.organization_id && record.respond_to?(:organization_id)
-
-
user.organization_id == record.organization_id
-
end
-
-
def has_permission?(permission_name)
-
return false unless user
-
-
user.has_permission?(permission_name)
-
end
-
-
def has_role?(role_name)
-
return false unless user
-
-
user.has_role?(role_name)
-
end
-
end
-
# frozen_string_literal: true
-
-
module Content
-
class DocumentPolicy < ApplicationPolicy
-
def index?
-
has_permission?("documents.read")
-
end
-
-
def show?
-
has_permission?("documents.read") && same_organization?
-
end
-
-
def create?
-
has_permission?("documents.create")
-
end
-
-
def update?
-
return true if admin?
-
return false unless has_permission?("documents.update")
-
return false unless same_organization?
-
-
# Check if user can update (owner or has manage permission)
-
owner? || has_permission?("documents.manage")
-
end
-
-
def destroy?
-
return true if admin?
-
return false unless has_permission?("documents.delete")
-
return false unless same_organization?
-
-
owner? || has_permission?("documents.manage")
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if admin?
-
scope.all
-
elsif user&.organization_id
-
scope.by_organization(user.organization_id)
-
else
-
scope.none
-
end
-
end
-
end
-
-
private
-
-
def same_organization?
-
return true if admin?
-
return true unless record.organization_id
-
-
record.organization_id == user&.organization_id
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Content
-
class FolderPolicy < ApplicationPolicy
-
def index?
-
has_permission?("documents.read")
-
end
-
-
def show?
-
has_permission?("documents.read") && same_organization?
-
end
-
-
def create?
-
has_permission?("documents.create")
-
end
-
-
def update?
-
return true if admin?
-
-
has_permission?("documents.update") && same_organization?
-
end
-
-
def destroy?
-
return true if admin?
-
-
has_permission?("documents.delete") && same_organization?
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if admin?
-
scope.all
-
elsif user&.organization_id
-
scope.by_organization(user.organization_id)
-
else
-
scope.none
-
end
-
end
-
end
-
-
private
-
-
def same_organization?
-
return true if admin?
-
return true unless record.organization_id
-
-
record.organization_id == user&.organization_id
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Hr
-
class EmployeePolicy < ApplicationPolicy
-
def index?
-
hr_staff? || supervisor?
-
end
-
-
def show?
-
owner? || hr_staff? || supervisor_of_record?
-
end
-
-
def create?
-
hr_staff? || admin?
-
end
-
-
def update?
-
hr_staff? || admin?
-
end
-
-
def create_account?
-
hr_staff? || admin?
-
end
-
-
def show_balance?
-
owner? || hr_staff? || supervisor_of_record?
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if user_employee.hr_staff? || user_employee.hr_manager?
-
scope.where(organization_id: user.organization_id)
-
elsif user_employee.supervisor?
-
# Supervisors see their subordinates plus themselves
-
scope.or(
-
{ id: user_employee.id },
-
{ supervisor_id: user_employee.id }
-
)
-
else
-
scope.where(id: user_employee.id)
-
end
-
end
-
-
private
-
-
def user_employee
-
@user_employee ||= ::Hr::Employee.for_user(user)
-
end
-
end
-
-
private
-
-
def owner?
-
record.id == user_employee&.id
-
end
-
-
def hr_staff?
-
user_employee&.hr_staff? || user_employee&.hr_manager?
-
end
-
-
def supervisor?
-
user_employee&.supervisor?
-
end
-
-
def supervisor_of_record?
-
user_employee&.supervises?(record)
-
end
-
-
def admin?
-
user&.admin?
-
end
-
-
def user_employee
-
@user_employee ||= ::Hr::Employee.for_user(user)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Hr
-
class EmploymentCertificationRequestPolicy < ApplicationPolicy
-
def index?
-
true # All authenticated users can list their own
-
end
-
-
def show?
-
owner? || hr_staff? || supervisor_of_owner?
-
end
-
-
def create?
-
true # All authenticated employees can create
-
end
-
-
def update?
-
owner? && record.pending?
-
end
-
-
def cancel?
-
return false if record.completed? || record.rejected?
-
-
hr_staff? || owner?
-
end
-
-
def generate_document?
-
hr_staff? # Only HR can generate documents
-
end
-
-
def sign_document?
-
hr_staff? || admin? # HR and Admin can sign documents
-
end
-
-
def destroy?
-
admin? # Only admin can delete certifications
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if user_employee.hr_staff? || user_employee.hr_manager?
-
scope.where(organization_id: user.organization_id)
-
else
-
scope.where(employee_id: user_employee.id)
-
end
-
end
-
-
private
-
-
def user_employee
-
@user_employee ||= ::Hr::Employee.for_user(user)
-
end
-
end
-
-
private
-
-
def owner?
-
record.employee_id == user_employee&.id
-
end
-
-
def hr_staff?
-
user_employee&.hr_staff? || user_employee&.hr_manager?
-
end
-
-
def supervisor_of_owner?
-
user_employee&.supervises?(record.employee)
-
end
-
-
def user_employee
-
@user_employee ||= ::Hr::Employee.for_user(user)
-
end
-
-
def admin?
-
user.has_role?(:admin)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Hr
-
class VacationRequestPolicy < ApplicationPolicy
-
def index?
-
true # All authenticated users can list their own
-
end
-
-
def show?
-
owner? || hr_staff? || supervisor_of_owner?
-
end
-
-
def create?
-
true # All authenticated employees can create
-
end
-
-
def update?
-
owner? && record.draft?
-
end
-
-
def submit?
-
owner? && record.draft?
-
end
-
-
def cancel?
-
return false if record.rejected?
-
-
hr_manager? || (owner? && can_employee_cancel?)
-
end
-
-
def destroy?
-
# Admin/HR can delete any request, owner can delete their own (with restrictions in controller)
-
admin? || hr_manager? || owner?
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if user_employee.hr_staff? || user_employee.hr_manager?
-
scope.where(organization_id: user.organization_id)
-
else
-
scope.where(employee_id: user_employee.id)
-
end
-
end
-
-
private
-
-
def user_employee
-
@user_employee ||= ::Hr::Employee.for_user(user)
-
end
-
end
-
-
private
-
-
def owner?
-
record.employee_id == user_employee&.id
-
end
-
-
def hr_staff?
-
user_employee&.hr_staff? || user_employee&.hr_manager?
-
end
-
-
def hr_manager?
-
user_employee&.hr_manager?
-
end
-
-
def supervisor_of_owner?
-
user_employee&.supervises?(record.employee)
-
end
-
-
def can_employee_cancel?
-
record.draft? || record.pending? || (record.approved? && record.start_date > Date.current)
-
end
-
-
def user_employee
-
@user_employee ||= ::Hr::Employee.for_user(user)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Identity
-
class UserPolicy < ApplicationPolicy
-
def index?
-
admin? || has_permission?("users.read")
-
end
-
-
def show?
-
admin? || has_permission?("users.read") || user == record
-
end
-
-
def create?
-
admin? || has_permission?("users.create")
-
end
-
-
def update?
-
admin? || has_permission?("users.update") || user == record
-
end
-
-
def destroy?
-
admin? || has_permission?("users.delete")
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if admin?
-
scope.all
-
elsif user&.has_permission?("users.read")
-
scope.where(organization_id: user.organization_id)
-
else
-
scope.none
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Legal
-
class ContractPolicy < ApplicationPolicy
-
def index?
-
# Allow all authenticated users - scoping will filter appropriately
-
true
-
end
-
-
def show?
-
admin? || legal_staff? || (manager? && owner_or_approver?) || signatory?
-
end
-
-
def create?
-
admin? || legal_staff? || manager?
-
end
-
-
def validate_template?
-
admin? || legal_staff? || manager?
-
end
-
-
def update?
-
return false unless record.editable?
-
admin? || legal_staff? || (manager? && owner?)
-
end
-
-
def destroy?
-
# Admin can delete any contract (for testing/cleanup purposes)
-
return true if admin?
-
# Others can only delete drafts they own
-
return false unless record.draft?
-
legal_staff? && owner?
-
end
-
-
def submit?
-
return false unless record.can_submit?
-
admin? || legal_staff? || (manager? && owner?)
-
end
-
-
def approve?
-
return false unless record.pending_approval?
-
record.can_approve?(user)
-
end
-
-
def reject?
-
approve?
-
end
-
-
def activate?
-
return false unless record.can_activate?
-
admin? || legal_staff?
-
end
-
-
def sign_document?
-
return false unless record.pending_signatures?
-
doc = record.generated_document
-
return false unless doc
-
doc.can_be_signed_by?(user)
-
end
-
-
def terminate?
-
return false unless record.active?
-
admin? || legal_staff?
-
end
-
-
def cancel?
-
return false if record.active? || record.expired? || record.terminated?
-
admin? || legal_staff? || (manager? && owner?)
-
end
-
-
def archive?
-
return false unless %w[active expired terminated cancelled].include?(record.status)
-
admin?
-
end
-
-
def unarchive?
-
return false unless record.archived?
-
admin?
-
end
-
-
def generate_document?
-
admin? || legal_staff?
-
end
-
-
def download_document?
-
admin? || legal_staff? || (manager? && owner_or_approver?) || signatory?
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if admin? || legal_staff?
-
scope.where(organization_id: user.organization_id)
-
elsif manager?
-
# Managers see contracts they created, need to approve, or need to sign
-
scope.where(organization_id: user.organization_id).any_of(
-
{ requested_by_id: user.id },
-
{ :status => "pending_approval" },
-
{ :status => "pending_signatures" }
-
)
-
else
-
# Other users only see contracts where they are signatories
-
scope.where(organization_id: user.organization_id, status: "pending_signatures")
-
end
-
end
-
-
private
-
-
def manager?
-
user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
-
end
-
-
def legal_staff?
-
user.has_role?("legal")
-
end
-
end
-
-
private
-
-
def legal_staff?
-
user.has_role?("legal")
-
end
-
-
def manager?
-
user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
-
end
-
-
def owner?
-
record.requested_by_id == user.id
-
end
-
-
def owner_or_approver?
-
owner? || record.can_approve?(user)
-
end
-
-
def signatory?
-
doc = record.generated_document
-
return false unless doc
-
doc.can_be_signed_by?(user)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Legal
-
class ThirdPartyPolicy < ApplicationPolicy
-
def index?
-
admin? || legal_staff? || manager?
-
end
-
-
def show?
-
admin? || legal_staff? || manager?
-
end
-
-
def create?
-
admin? || legal_staff?
-
end
-
-
def update?
-
admin? || legal_staff?
-
end
-
-
def destroy?
-
admin?
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if admin? || legal_staff? || manager?
-
scope.where(organization_id: user.organization_id)
-
else
-
scope.none
-
end
-
end
-
end
-
-
private
-
-
def legal_staff?
-
user.has_role?("legal")
-
end
-
-
def manager?
-
user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Legal
-
class ThirdPartyTypePolicy < ApplicationPolicy
-
def index?
-
admin? || legal_staff? || manager?
-
end
-
-
def show?
-
admin? || legal_staff? || manager?
-
end
-
-
def create?
-
admin? || legal_staff?
-
end
-
-
def update?
-
admin? || legal_staff?
-
end
-
-
def destroy?
-
admin?
-
end
-
-
def toggle_active?
-
admin? || legal_staff?
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if admin? || legal_staff? || manager?
-
scope.where(organization_id: user.organization_id)
-
else
-
scope.none
-
end
-
end
-
end
-
-
private
-
-
def legal_staff?
-
user.has_role?("legal")
-
end
-
-
def manager?
-
user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class SettingsPolicy < ApplicationPolicy
-
def show?
-
admin? || has_permission?("settings.read")
-
end
-
-
def update?
-
admin? || has_permission?("settings.manage")
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class GeneratedDocumentPolicy < ApplicationPolicy
-
def index?
-
true # All authenticated users can list their documents
-
end
-
-
def show?
-
owner? || can_sign? || employee_document? || hr_staff? || admin?
-
end
-
-
def preview?
-
show?
-
end
-
-
def download?
-
show?
-
end
-
-
def destroy?
-
admin? # Only admins can delete generated documents
-
end
-
-
def sign?
-
# User can sign if they have a pending signature on this document
-
record.can_be_signed_by?(user)
-
end
-
-
class Scope < ApplicationPolicy::Scope
-
def resolve
-
if user.admin? || hr_staff?
-
# HR and Admin can see all documents in the organization
-
scope.where(organization_id: user.organization_id)
-
else
-
# Regular users see documents they:
-
# 1. Requested
-
# 2. Are the employee on
-
# 3. Have a signature on (pending or signed)
-
employee = ::Hr::Employee.for_user(user)
-
employee_id = employee&.id
-
-
conditions = [{ requested_by_id: user.id }]
-
conditions << { employee_id: employee_id } if employee_id
-
conditions << { "signatures.user_id" => user.id.to_s }
-
-
scope.where(organization_id: user.organization_id).any_of(*conditions)
-
end
-
end
-
-
private
-
-
def hr_staff?
-
employee = ::Hr::Employee.for_user(user)
-
employee&.hr_staff? || employee&.hr_manager?
-
end
-
end
-
-
private
-
-
def owner?
-
record.requested_by_id == user.id
-
end
-
-
def can_sign?
-
record.signatures.any? { |s| s["user_id"] == user.id.to_s }
-
end
-
-
def employee_document?
-
employee = ::Hr::Employee.for_user(user)
-
employee && record.employee_id == employee.id
-
end
-
-
def hr_staff?
-
employee = ::Hr::Employee.for_user(user)
-
employee&.hr_staff? || employee&.hr_manager?
-
end
-
-
def admin?
-
user.admin?
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Audit
-
class LogEventService < BaseService
-
def initialize(event_type:, action:, target: nil, actor: nil, change_data: {}, metadata: {}, tags: [])
-
super()
-
@event_type = event_type
-
@action = action
-
@target = target
-
@actor = actor || Current.user
-
@change_data = change_data
-
@metadata = metadata
-
@tags = Array(tags)
-
end
-
-
def call
-
event = AuditEvent.log(
-
event_type: @event_type,
-
action: @action,
-
target: @target,
-
actor: @actor,
-
change_data: @change_data,
-
metadata: @metadata,
-
tags: @tags
-
)
-
-
success(event)
-
rescue StandardError => e
-
log_error("Failed to create audit event: #{e.message}")
-
failure("Failed to create audit event: #{e.message}")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class BaseService
-
attr_reader :result, :errors
-
-
def self.call(...)
-
new(...).call
-
end
-
-
def initialize
-
@result = nil
-
@errors = []
-
end
-
-
def call
-
raise NotImplementedError, "#{self.class}#call must be implemented"
-
end
-
-
def success?
-
errors.empty?
-
end
-
-
def failure?
-
!success?
-
end
-
-
protected
-
-
def success(result = nil)
-
@result = result
-
self
-
end
-
-
def failure(error_or_errors)
-
case error_or_errors
-
when Array
-
@errors.concat(error_or_errors)
-
when ActiveModel::Errors
-
@errors.concat(error_or_errors.full_messages)
-
else
-
@errors << error_or_errors.to_s
-
end
-
self
-
end
-
-
def add_error(message)
-
@errors << message
-
end
-
-
def current_user
-
Current.user
-
end
-
-
def current_organization
-
Current.organization
-
end
-
-
def log_info(message, **metadata)
-
Rails.logger.info("[#{self.class.name}] #{message}", **metadata)
-
end
-
-
def log_error(message, **metadata)
-
Rails.logger.error("[#{self.class.name}] #{message}", **metadata)
-
end
-
-
def log_warn(message, **metadata)
-
Rails.logger.warn("[#{self.class.name}] #{message}", **metadata)
-
end
-
end
-
# frozen_string_literal: true
-
-
class HealthCheckService < BaseService
-
def call
-
checks = {
-
mongodb: check_mongodb,
-
redis: check_redis,
-
app: app_running?
-
}
-
-
status = checks.values.all? ? "healthy" : "unhealthy"
-
-
success(
-
status: status,
-
checks: checks,
-
timestamp: Time.current.iso8601,
-
version: app_version
-
)
-
rescue StandardError => e
-
log_error("Health check failed: #{e.message}")
-
failure("Health check failed: #{e.message}")
-
end
-
-
private
-
-
def check_mongodb
-
HealthCheck.mongodb_connected?
-
rescue StandardError
-
false
-
end
-
-
def check_redis
-
return false unless defined?(Redis)
-
-
redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
-
Redis.new(url: redis_url).ping == "PONG"
-
rescue StandardError
-
false
-
end
-
-
def app_running?
-
Rails.application.present?
-
end
-
-
def app_version
-
ENV.fetch("APP_VERSION", "development")
-
end
-
end
-
# frozen_string_literal: true
-
-
module Hr
-
# Service to create user accounts for employees when contracts are generated
-
# Uses personal_email as username and identification_number as initial password
-
#
-
class EmployeeAccountService
-
class AccountCreationError < StandardError; end
-
-
attr_reader :employee, :errors
-
-
def initialize(employee)
-
@employee = employee
-
@errors = []
-
end
-
-
# Create a user account for the employee
-
# Returns the created user or nil if failed
-
def create_account!
-
validate_employee_data!
-
return nil if errors.any?
-
-
# Check if user already exists with this email
-
existing_user = Identity::User.where(email: employee.personal_email).first
-
if existing_user
-
@errors << "Ya existe un usuario con el correo #{employee.personal_email}"
-
return nil
-
end
-
-
user = build_user
-
-
if user.save
-
assign_employee_role(user)
-
link_employee_to_user(user)
-
user
-
else
-
@errors.concat(user.errors.full_messages)
-
nil
-
end
-
rescue StandardError => e
-
@errors << "Error al crear cuenta: #{e.message}"
-
Rails.logger.error "EmployeeAccountService error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
-
nil
-
end
-
-
# Check if employee can have an account created
-
def can_create_account?
-
validate_employee_data!
-
errors.empty?
-
end
-
-
# Check if employee already has a user account
-
def has_account?
-
employee.user_id.present? ||
-
(employee.personal_email.present? && Identity::User.exists?(email: employee.personal_email))
-
end
-
-
private
-
-
def validate_employee_data!
-
@errors = []
-
-
if employee.personal_email.blank?
-
@errors << "El empleado debe tener un correo personal"
-
end
-
-
if employee.identification_number.blank?
-
@errors << "El empleado debe tener número de identificación"
-
end
-
-
if employee.identification_number.present? && employee.identification_number.length < 6
-
@errors << "El número de identificación debe tener al menos 6 caracteres"
-
end
-
-
if employee.first_name.blank? || employee.last_name.blank?
-
@errors << "El empleado debe tener nombre y apellido"
-
end
-
end
-
-
def build_user
-
Identity::User.new(
-
email: employee.personal_email,
-
password: employee.identification_number,
-
password_confirmation: employee.identification_number,
-
first_name: extract_first_name,
-
last_name: extract_last_name,
-
organization: employee.organization,
-
must_change_password: true,
-
active: true
-
)
-
end
-
-
def extract_first_name
-
employee.display_first_name.presence || employee.personal_email.split('@').first.titleize
-
end
-
-
def extract_last_name
-
employee.display_last_name.presence || "Usuario"
-
end
-
-
def assign_employee_role(user)
-
employee_role = Identity::Role.where(name: Identity::Role::EMPLOYEE).first
-
user.roles << employee_role if employee_role && !user.roles.include?(employee_role)
-
end
-
-
def link_employee_to_user(user)
-
# Update employee to point to the new user
-
employee.update!(user_id: user.id)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Hr
-
# Main service for HR operations
-
# Handles vacation requests, certifications, and employee management
-
#
-
# rubocop:disable Metrics/ClassLength
-
class HrService
-
attr_reader :actor, :organization
-
-
def initialize(actor:, organization: nil)
-
@actor = actor
-
@organization = organization || actor.organization
-
@employee = find_or_create_employee(actor)
-
end
-
-
# ============================================
-
# Employee Management
-
# ============================================
-
-
def current_employee
-
@employee
-
end
-
-
def find_employee(user_or_id)
-
case user_or_id
-
when Hr::Employee
-
user_or_id
-
when Identity::User
-
Hr::Employee.find_by(user_id: user_or_id.id)
-
else
-
Hr::Employee.find(user_or_id)
-
end
-
end
-
-
def get_subordinates # rubocop:disable Naming/AccessorMethodName
-
return Hr::Employee.active.where(organization_id: organization.id) if @employee.hr_manager?
-
-
@employee.subordinates.active
-
end
-
-
def get_team_calendar(start_date, end_date)
-
employees = if @employee.hr_manager?
-
Hr::Employee.where(organization_id: organization.id).pluck(:id)
-
else
-
[@employee.id] + @employee.subordinates.pluck(:id)
-
end
-
-
VacationRequest
-
.where(:employee_id.in => employees)
-
.approved
-
.in_date_range(start_date, end_date)
-
.includes(:employee)
-
end
-
-
# ============================================
-
# Vacation Requests
-
# ============================================
-
-
def create_vacation_request(start_date:, end_date:, vacation_type: VacationRequest::TYPE_VACATION, reason: nil)
-
days = calculate_business_days(start_date, end_date)
-
-
request = VacationRequest.new(
-
employee: @employee,
-
organization: organization,
-
start_date: start_date,
-
end_date: end_date,
-
days_requested: days,
-
vacation_type: vacation_type,
-
reason: reason
-
)
-
-
request.save!
-
request
-
end
-
-
def submit_vacation_request(request)
-
ensure_own_request!(request)
-
request.submit!(actor: @employee)
-
end
-
-
def approve_vacation_request(request, reason: nil)
-
ensure_can_approve!(request)
-
request.approve!(actor: @employee, reason: reason)
-
end
-
-
def reject_vacation_request(request, reason:)
-
ensure_can_approve!(request)
-
request.reject!(actor: @employee, reason: reason)
-
end
-
-
def cancel_vacation_request(request, reason: nil)
-
raise AuthorizationError, "Cannot cancel this request" unless request.can_cancel?(@employee)
-
-
request.cancel!(actor: @employee, reason: reason)
-
end
-
-
def my_vacation_requests
-
@employee.vacation_requests.order(created_at: :desc)
-
end
-
-
def pending_approvals
-
if @employee.hr_manager?
-
VacationRequest
-
.where(organization_id: organization.id)
-
.pending
-
.order(submitted_at: :asc)
-
else
-
VacationRequest.for_approval_by(@employee).order(submitted_at: :asc)
-
end
-
end
-
-
def vacation_balance
-
{
-
available: @employee.vacation_balance_days,
-
used_ytd: @employee.vacation_used_ytd,
-
accrued_ytd: @employee.vacation_accrued_ytd,
-
carry_over: @employee.vacation_carry_over,
-
pending: pending_vacation_days
-
}
-
end
-
-
# ============================================
-
# Employment Certification Requests
-
# ============================================
-
-
def create_certification_request(
-
certification_type: EmploymentCertificationRequest::TYPE_EMPLOYMENT,
-
purpose: EmploymentCertificationRequest::PURPOSE_OTHER,
-
**
-
)
-
request = EmploymentCertificationRequest.new(
-
employee: @employee,
-
organization: organization,
-
certification_type: certification_type,
-
purpose: purpose,
-
**
-
)
-
-
request.save!
-
request
-
end
-
-
def cancel_certification_request(request, reason: nil)
-
raise AuthorizationError, "Cannot cancel this request" unless request.can_cancel?(@employee)
-
-
request.cancel!(actor: @employee, reason: reason)
-
end
-
-
def my_certification_requests
-
@employee.certification_requests.order(created_at: :desc)
-
end
-
-
def process_certification_request(request)
-
ensure_hr_staff!
-
request.start_processing!(actor: @employee)
-
end
-
-
def complete_certification_request(request, document_uuid: nil, notes: nil)
-
ensure_hr_staff!
-
request.complete!(actor: @employee, document_uuid: document_uuid, notes: notes)
-
end
-
-
def reject_certification_request(request, reason:)
-
ensure_hr_staff!
-
request.reject!(actor: @employee, reason: reason)
-
end
-
-
def pending_certifications
-
ensure_hr_staff!
-
EmploymentCertificationRequest
-
.where(organization_id: organization.id)
-
.for_processing
-
.order(submitted_at: :asc)
-
end
-
-
# ============================================
-
# Statistics (HR Dashboard)
-
# ============================================
-
-
def statistics
-
ensure_hr_staff!
-
-
{
-
employees: employee_stats,
-
vacation_requests: vacation_request_stats,
-
certification_requests: certification_request_stats
-
}
-
end
-
-
def employee_stats
-
ensure_hr_staff!
-
-
{
-
total: Hr::Employee.where(organization_id: organization.id).count,
-
active: Hr::Employee.active.where(organization_id: organization.id).count,
-
on_leave: Hr::Employee.on_leave.where(organization_id: organization.id).count,
-
by_department: Hr::Employee
-
.where(organization_id: organization.id)
-
.active
-
.group_by(&:department)
-
.transform_values(&:count)
-
}
-
end
-
-
def vacation_request_stats
-
base = VacationRequest.where(organization_id: organization.id)
-
current_year = Date.current.year
-
-
{
-
pending: base.pending.count,
-
approved_this_year: base.approved.where(:start_date.gte => Date.new(current_year, 1, 1)).count,
-
rejected_this_year: base.rejected.where(:created_at.gte => Date.new(current_year, 1, 1)).count,
-
employees_on_vacation_today: base.current.distinct(:employee_id).count
-
}
-
end
-
-
def certification_request_stats
-
base = EmploymentCertificationRequest.where(organization_id: organization.id)
-
current_year = Date.current.year
-
-
{
-
pending: base.pending.count,
-
processing: base.processing.count,
-
completed_this_year: base.completed.where(:completed_at.gte => Date.new(current_year, 1, 1)).count,
-
average_processing_days: calculate_avg_processing_time(base.completed)
-
}
-
end
-
-
private
-
-
def find_or_create_employee(user)
-
Hr::Employee.find_or_create_for_user!(
-
user.respond_to?(:user) ? user.user : user,
-
vacation_balance_days: 15.0 # Default balance for new employees
-
)
-
end
-
-
def calculate_business_days(start_date, end_date)
-
count = 0
-
(start_date..end_date).each do |date|
-
count += 1 unless date.saturday? || date.sunday?
-
end
-
count.to_f
-
end
-
-
def pending_vacation_days
-
@employee.vacation_requests
-
.where(:status.in => [VacationRequest::STATUS_PENDING, VacationRequest::STATUS_APPROVED])
-
.where(:start_date.gte => Date.current)
-
.sum(:days_requested)
-
end
-
-
def calculate_avg_processing_time(completed_requests)
-
return 0 if completed_requests.empty?
-
-
total_days = completed_requests.sum do |req|
-
next 0 unless req.completed_at && req.submitted_at
-
-
(req.completed_at.to_date - req.submitted_at.to_date).to_i
-
end
-
-
(total_days.to_f / completed_requests.count).round(1)
-
end
-
-
def ensure_own_request!(request)
-
return if request.employee_id == @employee.id
-
-
raise AuthorizationError, "Can only submit your own requests"
-
end
-
-
def ensure_can_approve!(request)
-
return if request.can_approve?(@employee)
-
-
raise AuthorizationError, "Not authorized to approve this request"
-
end
-
-
def ensure_hr_staff!
-
return if @employee.hr_staff?
-
-
raise AuthorizationError, "Only HR staff can perform this action"
-
end
-
-
class AuthorizationError < StandardError; end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
# frozen_string_literal: true
-
-
module Hr
-
# Calculates vacation entitlement according to Colombian Labor Law (CST Art. 186)
-
#
-
# Rules:
-
# - 15 business days of paid vacation per year of service
-
# - Vacation accrues proportionally from day one
-
# - Can accumulate up to 2 periods (30 days max pending)
-
# - After 4 years, can compensate in cash up to half of annual vacation
-
#
-
class VacationCalculator
-
DAYS_PER_YEAR = 15.0 # 15 días hábiles por año según ley colombiana
-
MAX_ACCUMULATION_YEARS = 2 # Máximo 2 períodos acumulables
-
DAYS_IN_YEAR = 365.0
-
-
attr_reader :employee
-
-
def initialize(employee)
-
@employee = employee
-
end
-
-
# Total days earned since hire date
-
def days_accrued
-
return 0.0 unless employee.hire_date
-
-
years_worked = years_of_service
-
(years_worked * DAYS_PER_YEAR).round(2)
-
end
-
-
# Days used (from employee record)
-
def days_used
-
employee.vacation_used_ytd || 0.0
-
end
-
-
# Days pending (accrued - used)
-
def days_pending
-
[days_accrued - total_days_used, 0.0].max.round(2)
-
end
-
-
# Days available to request (considering max accumulation)
-
def days_available
-
max_allowed = DAYS_PER_YEAR * MAX_ACCUMULATION_YEARS
-
[days_pending, max_allowed].min.round(2)
-
end
-
-
# Years of service (decimal)
-
def years_of_service
-
return 0.0 unless employee.hire_date
-
-
days_worked = (Date.current - employee.hire_date.to_date).to_f
-
(days_worked / DAYS_IN_YEAR).round(4)
-
end
-
-
# Complete years of service (integer)
-
def complete_years_of_service
-
years_of_service.floor
-
end
-
-
# Days accrued in current year (proportional)
-
def days_accrued_current_year
-
return 0.0 unless employee.hire_date
-
-
# Calculate from anniversary date or hire date in current year
-
anniversary_this_year = calculate_anniversary_this_year
-
days_since_anniversary = (Date.current - anniversary_this_year).to_f
-
-
return 0.0 if days_since_anniversary.negative?
-
-
((days_since_anniversary / DAYS_IN_YEAR) * DAYS_PER_YEAR).round(2)
-
end
-
-
# Days that will expire if not taken (over max accumulation)
-
def days_expiring
-
excess = days_pending - (DAYS_PER_YEAR * MAX_ACCUMULATION_YEARS)
-
[excess, 0.0].max.round(2)
-
end
-
-
# Can request cash compensation? (after 4 years, up to half)
-
def can_compensate_in_cash?
-
complete_years_of_service >= 4
-
end
-
-
# Max days that can be compensated in cash
-
def max_cash_compensation_days
-
return 0.0 unless can_compensate_in_cash?
-
-
(DAYS_PER_YEAR / 2).round(2) # Half of annual vacation
-
end
-
-
# Summary hash for API response
-
def summary
-
{
-
hire_date: employee.hire_date&.iso8601,
-
years_of_service: years_of_service.round(2),
-
complete_years: complete_years_of_service,
-
days_per_year: DAYS_PER_YEAR,
-
days_accrued_total: days_accrued,
-
days_accrued_current_year: days_accrued_current_year,
-
days_used_total: total_days_used,
-
days_used_current_year: days_used,
-
days_pending: days_pending,
-
days_available: days_available,
-
days_expiring: days_expiring,
-
max_accumulation_days: DAYS_PER_YEAR * MAX_ACCUMULATION_YEARS,
-
can_compensate_cash: can_compensate_in_cash?,
-
max_cash_compensation: max_cash_compensation_days
-
}
-
end
-
-
private
-
-
def total_days_used
-
# Sum all approved vacation requests
-
employee.vacation_requests
-
.where(status: "approved")
-
.sum(:days_requested) || 0.0
-
end
-
-
def calculate_anniversary_this_year
-
hire = employee.hire_date.to_date
-
anniversary = Date.new(Date.current.year, hire.month, hire.day)
-
-
# If anniversary hasn't happened yet this year, use last year's
-
anniversary > Date.current ? anniversary.prev_year : anniversary
-
rescue ArgumentError
-
# Handle Feb 29 for non-leap years
-
Date.new(Date.current.year, hire.month, hire.day - 1)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Retention
-
# Main service for retention management operations
-
# Handles policy application, schedule management, and legal holds
-
#
-
class RetentionService
-
attr_reader :user, :organization
-
-
def initialize(user:, organization: nil)
-
@user = user
-
@organization = organization || user.organization
-
end
-
-
# Apply retention policy to a document
-
def apply_policy(document, policy: nil)
-
# Find existing schedule or create new one
-
schedule = RetentionSchedule.where(document_id: document.id).first
-
-
if schedule
-
return schedule if schedule.under_legal_hold?
-
-
update_schedule_policy(schedule, policy || find_policy(document))
-
else
-
create_schedule(document, policy || find_policy(document))
-
end
-
end
-
-
# Find applicable policy for a document
-
def find_policy(document)
-
RetentionPolicy.find_policy_for(document, organization: organization)
-
end
-
-
# Get retention schedule for a document
-
def get_schedule(document)
-
RetentionSchedule.where(document_id: document.id).first
-
end
-
-
# Place a legal hold on a document
-
def place_legal_hold(document, name:, hold_type:, custodian_name:, **)
-
LegalHold.place_hold!(
-
document: document,
-
name: name,
-
hold_type: hold_type,
-
placed_by: user,
-
organization: organization,
-
custodian_name: custodian_name,
-
**
-
)
-
end
-
-
# Release a legal hold
-
def release_legal_hold(hold, reason:)
-
hold.release!(actor: user, reason: reason)
-
end
-
-
# Archive a document (soft action)
-
def archive_document(document, notes: nil)
-
schedule = get_schedule(document)
-
raise RetentionError, "No retention schedule found for document" unless schedule
-
raise RetentionError, "Document is under legal hold" if schedule.under_legal_hold?
-
-
schedule.archive!(actor: user, notes: notes)
-
end
-
-
# Mark document as expired
-
def expire_document(document, notes: nil)
-
schedule = get_schedule(document)
-
raise RetentionError, "No retention schedule found for document" unless schedule
-
raise RetentionError, "Document is under legal hold" if schedule.under_legal_hold?
-
-
schedule.expire!(actor: user, notes: notes)
-
end
-
-
# Extend retention period
-
def extend_retention(document, additional_days:, reason: nil)
-
schedule = get_schedule(document)
-
raise RetentionError, "No retention schedule found for document" unless schedule
-
-
schedule.extend_retention!(
-
additional_days: additional_days,
-
actor: user,
-
reason: reason
-
)
-
end
-
-
# Review a document's retention
-
def review_document(document, notes: nil)
-
schedule = get_schedule(document)
-
raise RetentionError, "No retention schedule found for document" unless schedule
-
-
schedule.record_review!(actor: user, notes: notes)
-
end
-
-
# Check if document can be modified
-
def modification_allowed?(document)
-
schedule = get_schedule(document)
-
return true unless schedule
-
-
schedule.modification_allowed?
-
end
-
-
# Check if document is under legal hold
-
def under_legal_hold?(document)
-
schedule = get_schedule(document)
-
return false unless schedule
-
-
schedule.under_legal_hold?
-
end
-
-
# Get documents expiring soon
-
def documents_expiring_soon(days: 30)
-
RetentionSchedule
-
.expiring_soon(days)
-
.where(organization_id: organization.id)
-
.includes(:document, :policy)
-
end
-
-
# Get documents past expiration
-
def documents_past_expiration
-
RetentionSchedule
-
.past_expiration
-
.where(organization_id: organization.id)
-
.includes(:document, :policy)
-
end
-
-
# Get documents under legal hold
-
def documents_under_hold
-
RetentionSchedule
-
.held
-
.where(organization_id: organization.id)
-
.includes(:document, :legal_holds)
-
end
-
-
# Get active legal holds
-
def active_legal_holds
-
LegalHold
-
.active
-
.where(organization_id: organization.id)
-
.includes(:schedule)
-
end
-
-
# Get statistics
-
# rubocop:disable Metrics/AbcSize
-
def statistics
-
{
-
total_scheduled: RetentionSchedule.where(organization_id: organization.id).count,
-
active: RetentionSchedule.active.where(organization_id: organization.id).count,
-
warning: RetentionSchedule.warning.where(organization_id: organization.id).count,
-
pending_action: RetentionSchedule.pending_action.where(organization_id: organization.id).count,
-
archived: RetentionSchedule.archived.where(organization_id: organization.id).count,
-
expired: RetentionSchedule.expired.where(organization_id: organization.id).count,
-
held: RetentionSchedule.held.where(organization_id: organization.id).count,
-
active_holds: LegalHold.active.where(organization_id: organization.id).count
-
}
-
end
-
# rubocop:enable Metrics/AbcSize
-
-
# Bulk apply policies to documents without schedules
-
# rubocop:disable Rails/PluckInWhere
-
def apply_policies_to_unscheduled_documents
-
documents = Content::Document
-
.where(organization_id: organization.id)
-
.where(:id.nin => RetentionSchedule.pluck(:document_id))
-
-
count = 0
-
documents.each do |doc|
-
policy = find_policy(doc)
-
next unless policy
-
-
create_schedule(doc, policy)
-
count += 1
-
end
-
-
count
-
end
-
# rubocop:enable Rails/PluckInWhere
-
-
private
-
-
def create_schedule(document, policy)
-
return nil unless policy
-
-
expiration_date = policy.calculate_expiration_date(document)
-
warning_date = policy.calculate_warning_date(document)
-
-
RetentionSchedule.create!(
-
document: document,
-
policy: policy,
-
organization: organization,
-
retention_start_date: Time.current,
-
expiration_date: expiration_date,
-
warning_date: warning_date
-
)
-
end
-
-
def update_schedule_policy(schedule, policy)
-
return schedule unless policy
-
return schedule if schedule.policy_id == policy.id
-
-
expiration_date = policy.calculate_expiration_date(schedule.document)
-
warning_date = policy.calculate_warning_date(schedule.document)
-
-
schedule.update!(
-
policy: policy,
-
expiration_date: expiration_date,
-
warning_date: warning_date
-
)
-
-
schedule
-
end
-
end
-
-
# Custom error class for retention operations
-
class RetentionError < StandardError; end
-
end
-
# frozen_string_literal: true
-
-
module Search
-
module Adapters
-
# Elasticsearch adapter for full-text search
-
# This is a placeholder implementation for future use
-
#
-
# To enable Elasticsearch:
-
# 1. Add elasticsearch gem to Gemfile
-
# 2. Configure connection in config/initializers/elasticsearch.rb
-
# 3. Set Search::SearchService.default_adapter_class = Search::Adapters::ElasticsearchAdapter
-
#
-
# Benefits over MongoDB:
-
# - True full-text search with linguistic analysis
-
# - Better relevance scoring (BM25)
-
# - Highlighting of matched terms
-
# - Faceted search/aggregations
-
# - Fuzzy matching and synonyms
-
# - Much better performance for large datasets
-
#
-
class ElasticsearchAdapter < BaseAdapter
-
INDEX_NAME = "valkyria_documents"
-
-
# Index settings for future implementation
-
INDEX_SETTINGS = {
-
settings: {
-
number_of_shards: 1,
-
number_of_replicas: 0,
-
analysis: {
-
analyzer: {
-
document_analyzer: {
-
type: "custom",
-
tokenizer: "standard",
-
filter: ["lowercase", "asciifolding", "porter_stem"]
-
}
-
}
-
}
-
},
-
mappings: {
-
properties: {
-
title: {
-
type: "text",
-
analyzer: "document_analyzer",
-
fields: { keyword: { type: "keyword" } }
-
},
-
description: {
-
type: "text",
-
analyzer: "document_analyzer"
-
},
-
content: {
-
type: "text",
-
analyzer: "document_analyzer"
-
},
-
tags: { type: "keyword" },
-
status: { type: "keyword" },
-
document_type: { type: "keyword" },
-
folder_id: { type: "keyword" },
-
organization_id: { type: "keyword" },
-
created_by_id: { type: "keyword" },
-
metadata: { type: "object", enabled: true },
-
created_at: { type: "date" },
-
updated_at: { type: "date" }
-
}
-
}
-
}.freeze
-
-
def initialize(options = {})
-
super
-
@client = options[:client] || build_client
-
end
-
-
def search(query)
-
raise NotImplementedError, "Elasticsearch adapter not yet implemented. Use MongoAdapter."
-
end
-
-
def index_document(document)
-
raise NotImplementedError, "Elasticsearch adapter not yet implemented"
-
end
-
-
def remove_document(document)
-
raise NotImplementedError, "Elasticsearch adapter not yet implemented"
-
end
-
-
def reindex_all(scope = nil)
-
raise NotImplementedError, "Elasticsearch adapter not yet implemented"
-
end
-
-
def healthy?
-
return false unless @client
-
-
@client.ping
-
rescue StandardError
-
false
-
end
-
-
def supports_full_text?
-
true
-
end
-
-
def supports_highlighting?
-
true
-
end
-
-
def supports_facets?
-
true
-
end
-
-
private
-
-
def build_client
-
# Placeholder - would use Elasticsearch::Client in real implementation
-
# Elasticsearch::Client.new(
-
# url: ENV.fetch('ELASTICSEARCH_URL', 'http://localhost:9200'),
-
# log: Rails.env.development?
-
# )
-
nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Search
-
module Adapters
-
# MongoDB-based search adapter
-
# Uses MongoDB queries for field-based search with regex support
-
#
-
# This adapter is suitable for:
-
# - Small to medium datasets (< 100k documents)
-
# - Field-based filtering (status, tags, metadata)
-
# - Simple text matching on titles and descriptions
-
#
-
# For large datasets or complex full-text search, consider:
-
# - ElasticsearchAdapter
-
# - MeilisearchAdapter
-
#
-
# rubocop:disable Metrics/ClassLength
-
class MongoAdapter < BaseAdapter
-
# Score weights for ranking
-
SCORE_WEIGHTS = {
-
title_exact: 100, # Exact title match
-
title_starts: 80, # Title starts with query
-
title_contains: 50, # Title contains query
-
description: 30, # Description match
-
tags: 40, # Tag match
-
metadata: 20, # Metadata match
-
recency_bonus: 10 # Bonus for recent documents
-
}.freeze
-
-
def search(query)
-
return invalid_query_result(query) unless query.valid?
-
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
scope = build_scope(query)
-
total_count = scope.count
-
-
# Apply sorting and pagination
-
results = apply_sorting(scope, query)
-
.skip(query.offset)
-
.limit(query.per_page)
-
.to_a
-
-
# Calculate scores for ranking
-
scored_results = calculate_scores(results, query)
-
-
query_time = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
-
-
Results.new(
-
documents: scored_results,
-
total_count: total_count,
-
page: query.page,
-
per_page: query.per_page,
-
query_time_ms: query_time,
-
metadata: build_metadata(query, scope)
-
)
-
end
-
-
def healthy?
-
Content::Document.collection.database.command(ping: 1)
-
true
-
rescue StandardError
-
false
-
end
-
-
def supports_full_text?
-
# MongoDB supports text indexes, but we're using regex for more control
-
false
-
end
-
-
private
-
-
def invalid_query_result(query)
-
Results.new(
-
documents: [],
-
total_count: 0,
-
page: query.page,
-
per_page: query.per_page,
-
metadata: { errors: query.errors }
-
)
-
end
-
-
def build_scope(query)
-
scope = base_scope_for_query(query)
-
scope = apply_text_search(scope, query) if query.text?
-
scope = apply_filters(scope, query)
-
apply_permission_filters(scope, query.user, folder_ids: query.filter(:folder_ids))
-
end
-
-
def base_scope_for_query(query)
-
if query.include_deleted
-
Content::Document.unscoped.where(organization_id: query.organization_id)
-
else
-
Content::Document.where(organization_id: query.organization_id, deleted_at: nil)
-
end
-
end
-
-
def apply_text_search(scope, query)
-
text = Regexp.escape(query.text)
-
regex = /#{text}/i
-
-
# Search in title, description, tags, and document_type using $or
-
# We need to use and() to combine with existing criteria properly
-
scope.and(
-
"$or" => [
-
{ title: regex },
-
{ description: regex },
-
{ tags: regex },
-
{ document_type: regex }
-
]
-
)
-
end
-
-
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
-
def apply_filters(scope, query)
-
filters = query.filters
-
-
# Title/name filter (exact or partial)
-
if filters[:title].present?
-
scope = scope.where(title: /#{Regexp.escape(filters[:title])}/i)
-
elsif filters[:name].present?
-
scope = scope.where(title: /#{Regexp.escape(filters[:name])}/i)
-
end
-
-
# Tags filter (match any)
-
scope = scope.where(:tags.in => Array(filters[:tags])) if filters[:tags].present?
-
-
# Status filter
-
scope = scope.where(status: filters[:status]) if filters[:status].present?
-
-
# Document type filter
-
scope = scope.where(document_type: filters[:document_type]) if filters[:document_type].present?
-
-
# Folder filters
-
if filters[:folder_ids].present?
-
scope = scope.where(:folder_id.in => filters[:folder_ids])
-
elsif filters[:folder_id].present?
-
scope = scope.where(folder_id: filters[:folder_id])
-
end
-
-
# Creator filter
-
scope = scope.where(created_by_id: filters[:created_by_id]) if filters[:created_by_id].present?
-
-
# Metadata filters (nested hash search)
-
scope = apply_metadata_filters(scope, filters[:metadata]) if filters[:metadata].present?
-
-
# Date range filters
-
apply_date_filters(scope, filters)
-
end
-
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
-
-
def apply_metadata_filters(scope, metadata)
-
metadata.each do |key, value|
-
# Sanitize key to prevent injection (only allow alphanumeric and underscore)
-
sanitized_key = key.to_s.gsub(/[^a-zA-Z0-9_]/, "")
-
next if sanitized_key.empty?
-
-
scope = scope.where("metadata.#{sanitized_key}" => value) # brakeman:disable SQLInjection
-
end
-
scope
-
end
-
-
def apply_date_filters(scope, filters)
-
scope = scope.where(:created_at.gte => filters[:created_after]) if filters[:created_after]
-
scope = scope.where(:created_at.lte => filters[:created_before]) if filters[:created_before]
-
scope = scope.where(:updated_at.gte => filters[:updated_after]) if filters[:updated_after]
-
scope = scope.where(:updated_at.lte => filters[:updated_before]) if filters[:updated_before]
-
scope
-
end
-
-
def apply_sorting(scope, query)
-
if query.text? && query.sort == Search::Query::SORT_OPTIONS[:relevance]
-
# For relevance sorting with text search, we'll sort by score later
-
scope.order(updated_at: :desc)
-
else
-
scope.order(query.sort)
-
end
-
end
-
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
-
def calculate_scores(documents, query)
-
return documents unless query.text?
-
-
text = query.text.downcase
-
now = Time.current
-
-
scored = documents.map do |doc|
-
score = 0
-
-
# Title scoring
-
title_lower = doc.title.to_s.downcase
-
if title_lower == text
-
score += SCORE_WEIGHTS[:title_exact]
-
elsif title_lower.start_with?(text)
-
score += SCORE_WEIGHTS[:title_starts]
-
elsif title_lower.include?(text)
-
score += SCORE_WEIGHTS[:title_contains]
-
end
-
-
# Description scoring
-
score += SCORE_WEIGHTS[:description] if doc.description.to_s.downcase.include?(text)
-
-
# Tags scoring
-
score += SCORE_WEIGHTS[:tags] if doc.tags.any? { |tag| tag.to_s.downcase.include?(text) }
-
-
# Metadata scoring
-
score += SCORE_WEIGHTS[:metadata] if doc.metadata.to_s.downcase.include?(text)
-
-
# Recency bonus (documents updated in last 7 days get bonus)
-
if doc.updated_at && doc.updated_at > 7.days.ago
-
days_old = [(now - doc.updated_at) / 1.day, 1].max
-
recency_score = (SCORE_WEIGHTS[:recency_bonus] / days_old).round
-
score += recency_score
-
end
-
-
# Attach score to document for display
-
doc.define_singleton_method(:search_score) { score }
-
doc
-
end
-
-
# Sort by score descending if relevance sorting
-
if query.sort == Search::Query::SORT_OPTIONS[:relevance]
-
scored.sort_by { |doc| -doc.search_score }
-
else
-
scored
-
end
-
end
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
-
-
def build_metadata(query, _scope)
-
{
-
adapter: adapter_name,
-
query_text: query.text,
-
filters_applied: query.filters.keys,
-
sort: query.sort
-
}
-
end
-
end
-
# rubocop:enable Metrics/ClassLength
-
end
-
end
-
# frozen_string_literal: true
-
-
module Search
-
# Abstract base class for search adapters
-
# Defines the interface that all search backends must implement
-
#
-
# This adapter pattern allows plugging in different search backends:
-
# - MongoAdapter: Uses MongoDB queries (current implementation)
-
# - ElasticsearchAdapter: For full-text search (future)
-
# - MeilisearchAdapter: Alternative full-text (future)
-
# - TypesenseAdapter: Another alternative (future)
-
#
-
class BaseAdapter
-
attr_reader :options
-
-
def initialize(options = {})
-
@options = options
-
end
-
-
# Search for documents matching the given query
-
#
-
# @param query [Search::Query] The search query object
-
# @return [Search::Results] The search results
-
def search(query)
-
raise NotImplementedError, "#{self.class}#search must be implemented"
-
end
-
-
# Index a document for searching
-
# Used by full-text backends to update their index
-
#
-
# @param document [Content::Document] The document to index
-
# @return [Boolean] Success status
-
# rubocop:disable Naming/PredicateMethod
-
def index_document(_document)
-
# Default no-op for backends that don't need indexing (like MongoDB)
-
true
-
end
-
-
# Remove a document from the search index
-
#
-
# @param document [Content::Document] The document to remove
-
# @return [Boolean] Success status
-
def remove_document(_document)
-
# Default no-op for backends that don't need index management
-
true
-
end
-
# rubocop:enable Naming/PredicateMethod
-
-
# Reindex all documents
-
# Used during initial setup or after schema changes
-
#
-
# @param scope [Mongoid::Criteria] Optional scope to limit reindexing
-
# @return [Integer] Number of documents reindexed
-
def reindex_all(_scope = nil)
-
# Default no-op
-
0
-
end
-
-
# Check if the search backend is available
-
#
-
# @return [Boolean] Health status
-
def healthy?
-
raise NotImplementedError, "#{self.class}#healthy? must be implemented"
-
end
-
-
# Get adapter name for identification
-
#
-
# @return [String] Adapter name
-
def adapter_name
-
self.class.name.demodulize.underscore.sub(/_adapter$/, "")
-
end
-
-
# Check if this adapter supports full-text search
-
#
-
# @return [Boolean]
-
def supports_full_text?
-
false
-
end
-
-
# Check if this adapter supports highlighting
-
#
-
# @return [Boolean]
-
def supports_highlighting?
-
false
-
end
-
-
# Check if this adapter supports faceted search
-
#
-
# @return [Boolean]
-
def supports_facets?
-
false
-
end
-
-
protected
-
-
# Build base scope with organization filter
-
def base_scope(organization_id)
-
Content::Document.active.by_organization(organization_id)
-
end
-
-
# Apply permission filters to scope
-
def apply_permission_filters(scope, user, options = {})
-
return scope if user.admin?
-
-
# For non-admin users, filter based on folder access
-
# This is a simplified implementation - can be extended with ACLs
-
if options[:folder_ids].present?
-
scope.where(:folder_id.in => options[:folder_ids])
-
else
-
scope
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Search
-
# Represents a search query with all parameters
-
# Provides a clean interface for building complex search queries
-
#
-
class Query
-
attr_accessor :text, :filters, :sort, :page, :per_page, :user, :organization_id,
-
:include_deleted, :highlight, :facets
-
-
# Filter keys that are supported
-
SUPPORTED_FILTERS = [
-
:title,
-
:name,
-
:tags,
-
:status,
-
:document_type,
-
:folder_id,
-
:folder_ids,
-
:created_by_id,
-
:metadata,
-
:created_after,
-
:created_before,
-
:updated_after,
-
:updated_before
-
].freeze
-
-
# Sort options
-
SORT_OPTIONS = {
-
relevance: { _score: :desc },
-
newest: { created_at: :desc },
-
oldest: { created_at: :asc },
-
title_asc: { title: :asc },
-
title_desc: { title: :desc },
-
updated: { updated_at: :desc }
-
}.freeze
-
-
# rubocop:disable Metrics/PerceivedComplexity
-
def initialize(params = {})
-
@text = params[:text] || params[:q] || ""
-
@filters = normalize_filters(params[:filters] || {})
-
@sort = normalize_sort(params[:sort])
-
@page = (params[:page] || 1).to_i
-
@per_page = [(params[:per_page] || 20).to_i, 100].min
-
@user = params[:user]
-
@organization_id = params[:organization_id]
-
@include_deleted = params[:include_deleted] || false
-
@highlight = params[:highlight] || false
-
@facets = params[:facets] || []
-
end
-
# rubocop:enable Metrics/PerceivedComplexity
-
-
def text?
-
text.present? && text.length >= 2
-
end
-
-
def has_filters? # rubocop:disable Naming/PredicatePrefix
-
filters.any?
-
end
-
-
def filter(key)
-
filters[key.to_sym]
-
end
-
-
def add_filter(key, value)
-
@filters[key.to_sym] = value if SUPPORTED_FILTERS.include?(key.to_sym)
-
self
-
end
-
-
def remove_filter(key)
-
@filters.delete(key.to_sym)
-
self
-
end
-
-
def offset
-
(page - 1) * per_page
-
end
-
-
def sort_field
-
sort.keys.first
-
end
-
-
def sort_direction
-
sort.values.first
-
end
-
-
def valid?
-
errors.empty?
-
end
-
-
def errors
-
errs = []
-
errs << "Organization ID is required" if organization_id.blank?
-
errs << "User is required" if user.blank?
-
errs << "Search text too short (minimum 2 characters)" if text.present? && text.length < 2
-
errs << "Page must be positive" if page < 1
-
errs << "Per page must be positive" if per_page < 1
-
errs
-
end
-
-
def to_h
-
{
-
text: text,
-
filters: filters,
-
sort: sort,
-
page: page,
-
per_page: per_page,
-
organization_id: organization_id&.to_s,
-
include_deleted: include_deleted
-
}
-
end
-
-
private
-
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
-
def normalize_filters(raw_filters)
-
normalized = {}
-
raw_filters.each do |key, value|
-
sym_key = key.to_sym
-
next unless SUPPORTED_FILTERS.include?(sym_key)
-
next if value.blank?
-
-
normalized[sym_key] = case sym_key
-
when :tags
-
Array(value)
-
when :folder_ids
-
Array(value).map { |id| normalize_id(id) }
-
when :folder_id, :created_by_id
-
normalize_id(value)
-
when :metadata
-
value.is_a?(Hash) ? value : {}
-
when :created_after, :created_before, :updated_after, :updated_before
-
parse_time(value)
-
else
-
value
-
end
-
end
-
normalized
-
end
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
-
-
# rubocop:disable Metrics/PerceivedComplexity
-
def normalize_sort(sort_param)
-
return SORT_OPTIONS[:relevance] if sort_param.blank?
-
-
if sort_param.is_a?(Hash)
-
sort_param.transform_keys(&:to_sym).transform_values(&:to_sym)
-
elsif sort_param.is_a?(Symbol) || sort_param.is_a?(String)
-
SORT_OPTIONS[sort_param.to_sym] || SORT_OPTIONS[:relevance]
-
else
-
SORT_OPTIONS[:relevance]
-
end
-
end
-
# rubocop:enable Metrics/PerceivedComplexity
-
-
def normalize_id(value)
-
return value if value.is_a?(BSON::ObjectId)
-
-
BSON::ObjectId.from_string(value.to_s)
-
rescue BSON::Error::InvalidObjectId
-
nil
-
end
-
-
def parse_time(value)
-
return value if value.is_a?(Time) || value.is_a?(DateTime)
-
-
Time.zone.parse(value.to_s)
-
rescue ArgumentError
-
nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Search
-
# Represents search results with pagination and metadata
-
#
-
class Results
-
include Enumerable
-
-
attr_reader :documents, :total_count, :page, :per_page, :query_time_ms,
-
:facets, :highlights, :metadata
-
-
def initialize(documents:, total_count:, page:, per_page:, query_time_ms: 0, **options)
-
@documents = documents
-
@total_count = total_count
-
@page = page
-
@per_page = per_page
-
@query_time_ms = query_time_ms
-
@facets = options[:facets] || {}
-
@highlights = options[:highlights] || {}
-
@metadata = options[:metadata] || {}
-
end
-
-
def each(&)
-
documents.each(&)
-
end
-
-
delegate :empty?, to: :documents
-
-
delegate :size, to: :documents
-
-
alias count size
-
-
def total_pages
-
return 0 if total_count.zero?
-
-
(total_count.to_f / per_page).ceil
-
end
-
-
def current_page
-
page
-
end
-
-
def next_page
-
page < total_pages ? page + 1 : nil
-
end
-
-
def prev_page
-
page > 1 ? page - 1 : nil
-
end
-
-
def first_page?
-
page == 1
-
end
-
-
def last_page?
-
page >= total_pages
-
end
-
-
def offset
-
(page - 1) * per_page
-
end
-
-
def has_more? # rubocop:disable Naming/PredicatePrefix
-
!last_page?
-
end
-
-
# Get highlight for a specific document
-
def highlight_for(document)
-
highlights[document.id.to_s] || {}
-
end
-
-
# Pagination info for API responses
-
def pagination
-
{
-
current_page: page,
-
per_page: per_page,
-
total_pages: total_pages,
-
total_count: total_count,
-
has_next: has_more?,
-
has_prev: prev_page.present?
-
}
-
end
-
-
# Full response for API
-
def to_h
-
{
-
documents: documents.map { |d| document_to_h(d) },
-
pagination: pagination,
-
facets: facets,
-
metadata: metadata.merge(query_time_ms: query_time_ms)
-
}
-
end
-
-
# Create empty results
-
def self.empty(page: 1, per_page: 20)
-
new(
-
documents: [],
-
total_count: 0,
-
page: page,
-
per_page: per_page
-
)
-
end
-
-
private
-
-
def document_to_h(doc)
-
{
-
id: doc.id.to_s,
-
uuid: doc.uuid,
-
title: doc.title,
-
description: doc.description,
-
status: doc.status,
-
document_type: doc.document_type,
-
tags: doc.tags,
-
folder_id: doc.folder_id&.to_s,
-
created_at: doc.created_at&.iso8601,
-
updated_at: doc.updated_at&.iso8601,
-
version_count: doc.version_count,
-
score: doc.try(:search_score)
-
}.compact
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Search
-
# Main search service that coordinates search operations
-
# Uses the configured adapter to perform searches
-
#
-
# Usage:
-
# service = Search::SearchService.new(user: current_user, organization: current_org)
-
# results = service.search("quarterly report", filters: { status: "published" })
-
#
-
# Or with the class method:
-
# results = Search::SearchService.search(
-
# text: "quarterly report",
-
# user: current_user,
-
# organization_id: org.id,
-
# filters: { tags: ["finance"] }
-
# )
-
#
-
class SearchService
-
attr_reader :adapter, :user, :organization_id
-
-
# Configure the default adapter
-
class << self
-
attr_accessor :default_adapter_class
-
-
def search(params)
-
new(
-
user: params[:user],
-
organization_id: params[:organization_id] || params[:user]&.organization_id
-
).search(params[:text] || params[:q], params.except(:user, :organization_id, :text, :q))
-
end
-
-
def default_adapter
-
@default_adapter ||= Adapters::MongoAdapter
-
end
-
end
-
-
def initialize(user:, organization_id: nil, adapter: nil)
-
@user = user
-
@organization_id = organization_id || user&.organization_id
-
@adapter = adapter || self.class.default_adapter.new
-
end
-
-
# Perform a search
-
#
-
# @param text [String] The search text (optional)
-
# @param options [Hash] Search options
-
# @option options [Hash] :filters Field filters
-
# @option options [Symbol, Hash] :sort Sort order
-
# @option options [Integer] :page Page number
-
# @option options [Integer] :per_page Results per page
-
# @return [Search::Results]
-
#
-
def search(text = nil, options = {})
-
query = build_query(text, options)
-
-
# Log search for audit trail
-
log_search(query)
-
-
adapter.search(query)
-
end
-
-
# Search by title/name
-
def search_by_title(title, options = {})
-
search(nil, options.merge(filters: (options[:filters] || {}).merge(title: title)))
-
end
-
-
# Search by tags
-
def search_by_tags(tags, options = {})
-
search(nil, options.merge(filters: (options[:filters] || {}).merge(tags: Array(tags))))
-
end
-
-
# Search by metadata
-
def search_by_metadata(metadata, options = {})
-
search(nil, options.merge(filters: (options[:filters] || {}).merge(metadata: metadata)))
-
end
-
-
# Search within a specific folder
-
def search_in_folder(folder_or_id, text = nil, options = {})
-
folder_id = folder_or_id.is_a?(Content::Folder) ? folder_or_id.id : folder_or_id
-
search(text, options.merge(filters: (options[:filters] || {}).merge(folder_id: folder_id)))
-
end
-
-
# Search within multiple folders
-
def search_in_folders(folder_ids, text = nil, options = {})
-
ids = folder_ids.map { |f| f.is_a?(Content::Folder) ? f.id : f }
-
search(text, options.merge(filters: (options[:filters] || {}).merge(folder_ids: ids)))
-
end
-
-
# Get documents by status
-
def by_status(status, options = {})
-
search(nil, options.merge(filters: (options[:filters] || {}).merge(status: status)))
-
end
-
-
# Get recent documents
-
def recent(limit = 10)
-
search(nil, sort: :newest, per_page: limit)
-
end
-
-
# Get documents created by a specific user
-
def by_creator(user_or_id, options = {})
-
creator_id = user_or_id.is_a?(Identity::User) ? user_or_id.id : user_or_id
-
search(nil, options.merge(filters: (options[:filters] || {}).merge(created_by_id: creator_id)))
-
end
-
-
# Advanced search with multiple criteria
-
#
-
# @param criteria [Hash] Search criteria
-
# @option criteria [String] :text Free text search
-
# @option criteria [String] :title Title filter
-
# @option criteria [Array<String>] :tags Tag filters
-
# @option criteria [String] :status Status filter
-
# @option criteria [String] :document_type Document type filter
-
# @option criteria [Hash] :metadata Metadata filters
-
# @option criteria [Time] :created_after Created after date
-
# @option criteria [Time] :created_before Created before date
-
#
-
def advanced_search(criteria = {})
-
text = criteria.delete(:text) || criteria.delete(:q)
-
filters = criteria.slice(*Query::SUPPORTED_FILTERS)
-
options = criteria.except(*Query::SUPPORTED_FILTERS)
-
-
search(text, options.merge(filters: filters))
-
end
-
-
# Check if adapter is healthy
-
delegate :healthy?, to: :adapter
-
-
private
-
-
def build_query(text, options)
-
Query.new(
-
text: text,
-
filters: options[:filters] || {},
-
sort: options[:sort],
-
page: options[:page],
-
per_page: options[:per_page],
-
user: user,
-
organization_id: organization_id,
-
include_deleted: options[:include_deleted],
-
highlight: options[:highlight],
-
facets: options[:facets]
-
)
-
end
-
-
def log_search(query)
-
return unless query.valid?
-
-
Audit::AuditEvent.create(
-
event_type: Audit::AuditEvent::TYPES[:content],
-
action: "search_performed",
-
actor_id: user&.id,
-
actor_type: user&.class&.name,
-
actor_email: user.try(:email),
-
organization_id: organization_id,
-
metadata: {
-
search_text: query.text.presence,
-
filters: query.filters,
-
adapter: adapter.adapter_name
-
},
-
tags: ["search"]
-
)
-
rescue StandardError => e
-
Rails.logger.warn("Failed to log search audit: #{e.message}")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class ServiceResult
-
attr_reader :data, :errors, :metadata
-
-
def initialize(success:, data: nil, errors: [], metadata: {})
-
@success = success
-
@data = data
-
@errors = Array(errors)
-
@metadata = metadata
-
end
-
-
def self.success(data = nil, metadata: {})
-
new(success: true, data: data, metadata: metadata)
-
end
-
-
def self.failure(errors, metadata: {})
-
new(success: false, errors: Array(errors), metadata: metadata)
-
end
-
-
def success?
-
@success
-
end
-
-
def failure?
-
!success?
-
end
-
-
def error_messages
-
errors.join(", ")
-
end
-
-
def to_h
-
{
-
success: success?,
-
data: data,
-
errors: errors,
-
metadata: metadata
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class DocumentGeneratorService
-
attr_reader :template, :context, :variable_values
-
-
def initialize(template, context)
-
@template = template
-
@context = context
-
@variable_values = {}
-
end
-
-
# Generate a document from template
-
def generate!
-
validate_template!
-
resolve_variables!
-
generate_document!
-
end
-
-
private
-
-
def validate_template!
-
raise GenerationError, "Template no activo" unless template.active?
-
raise GenerationError, "Template sin archivo" unless template.file_id
-
end
-
-
def resolve_variables!
-
resolver = VariableResolverService.new(context)
-
@variable_values = resolver.resolve_for_template(template)
-
end
-
-
def generate_document!
-
# Get template content
-
template_content = template.file_content
-
raise GenerationError, "No se pudo leer el archivo del template" unless template_content
-
-
# Create temp file for processing
-
input_file = Tempfile.new(["template", ".docx"])
-
input_file.binmode
-
input_file.write(template_content)
-
input_file.rewind
-
-
begin
-
# Replace variables in the document
-
doc = Docx::Document.open(input_file.path)
-
replace_variables_in_document!(doc)
-
-
# Save modified document
-
output_docx = Tempfile.new(["output", ".docx"])
-
doc.save(output_docx.path)
-
-
# Convert to PDF
-
pdf_content = convert_to_pdf(output_docx.path)
-
-
# Create GeneratedDocument record
-
create_generated_document(pdf_content)
-
ensure
-
input_file.close
-
input_file.unlink
-
output_docx&.close
-
output_docx&.unlink
-
end
-
end
-
-
def replace_variables_in_document!(doc)
-
# Replace in paragraphs
-
doc.paragraphs.each do |para|
-
replace_in_paragraph!(para)
-
end
-
-
# Replace in tables
-
doc.tables.each do |table|
-
table.rows.each do |row|
-
row.cells.each do |cell|
-
cell.paragraphs.each do |para|
-
replace_in_paragraph!(para)
-
end
-
end
-
end
-
end
-
end
-
-
def replace_in_paragraph!(para)
-
variable_values.each do |variable_name, value|
-
pattern = "{{#{variable_name}}}"
-
replacement = value.to_s
-
-
# Handle paragraph text replacement
-
para.each_text_run do |run|
-
run.text = run.text.gsub(pattern, replacement) if run.text.include?(pattern)
-
end
-
end
-
end
-
-
def convert_to_pdf(docx_path)
-
# Try using LibreOffice for conversion (if available)
-
if libreoffice_available?
-
convert_with_libreoffice(docx_path)
-
else
-
# Fallback to Prawn-based PDF generation
-
convert_with_prawn(docx_path)
-
end
-
end
-
-
def libreoffice_available?
-
# Check common LibreOffice paths on macOS, Linux, and Heroku
-
paths_to_check = [
-
"/app/.apt/usr/bin/soffice", # Heroku apt buildpack path
-
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
-
"/usr/local/bin/soffice",
-
"/usr/bin/soffice",
-
"/usr/bin/libreoffice"
-
]
-
-
@libreoffice_path = paths_to_check.find { |p| File.exist?(p) }
-
@libreoffice_path ||= `which soffice 2>/dev/null`.strip.presence
-
@libreoffice_path ||= `which libreoffice 2>/dev/null`.strip.presence
-
-
@libreoffice_path.present?
-
end
-
-
def convert_with_libreoffice(docx_path)
-
output_dir = Dir.mktmpdir
-
user_profile = Dir.mktmpdir("lo_profile")
-
-
begin
-
# Set environment for Heroku apt buildpack
-
lib_path = "/app/.apt/usr/lib/libreoffice/program:/app/.apt/usr/lib/x86_64-linux-gnu"
-
env_vars = [
-
"LD_LIBRARY_PATH=#{lib_path}:$LD_LIBRARY_PATH",
-
"HOME=/tmp"
-
].join(" ")
-
-
# Use -env:UserInstallation to avoid profile issues
-
user_install = "-env:UserInstallation=file://#{user_profile}"
-
-
cmd = "#{env_vars} \"#{@libreoffice_path}\" --headless #{user_install} --convert-to pdf --outdir \"#{output_dir}\" \"#{docx_path}\" 2>&1"
-
Rails.logger.info "Running LibreOffice: #{cmd}"
-
result = `#{cmd}`
-
Rails.logger.info "LibreOffice conversion result: #{result}"
-
-
# Find the generated PDF
-
pdf_files = Dir.glob(File.join(output_dir, "*.pdf"))
-
if pdf_files.empty?
-
Rails.logger.error "LibreOffice conversion failed, falling back to Prawn"
-
return convert_with_prawn(docx_path)
-
end
-
-
File.binread(pdf_files.first)
-
ensure
-
FileUtils.rm_rf(output_dir)
-
FileUtils.rm_rf(user_profile)
-
end
-
end
-
-
def convert_with_prawn(docx_path)
-
# Fallback: Generate a basic PDF with Prawn
-
doc = Docx::Document.open(docx_path)
-
-
Prawn::Document.new do |pdf|
-
pdf.font_families.update(
-
"DejaVu" => {
-
normal: Rails.root.join("app/assets/fonts/DejaVuSans.ttf").to_s,
-
bold: Rails.root.join("app/assets/fonts/DejaVuSans-Bold.ttf").to_s,
-
italic: Rails.root.join("app/assets/fonts/DejaVuSans-Oblique.ttf").to_s
-
}
-
) if File.exist?(Rails.root.join("app/assets/fonts/DejaVuSans.ttf"))
-
-
pdf.font("DejaVu") if pdf.font_families.key?("DejaVu")
-
-
doc.paragraphs.each do |para|
-
text = para.text.strip
-
next if text.empty?
-
-
# Basic styling
-
if para.node["pStyle"]&.include?("Heading")
-
pdf.text text, size: 16, style: :bold
-
pdf.move_down 10
-
else
-
pdf.text text, size: 11
-
pdf.move_down 5
-
end
-
end
-
-
# Handle tables
-
doc.tables.each do |table|
-
table_data = table.rows.map do |row|
-
row.cells.map { |cell| cell.paragraphs.map(&:text).join("\n") }
-
end
-
-
if table_data.any?
-
pdf.table(table_data, width: pdf.bounds.width) do
-
cells.padding = 5
-
cells.border_width = 0.5
-
end
-
pdf.move_down 10
-
end
-
end
-
end.render
-
end
-
-
def create_generated_document(pdf_content)
-
# Store PDF in GridFS
-
file_name = "#{template.name.parameterize}-#{Time.current.strftime('%Y%m%d%H%M%S')}.pdf"
-
-
pdf_file = Mongoid::GridFs.put(
-
StringIO.new(pdf_content),
-
filename: file_name,
-
content_type: "application/pdf"
-
)
-
-
# Create the GeneratedDocument record
-
generated_doc = GeneratedDocument.create!(
-
name: file_name,
-
template: template,
-
organization: context[:organization],
-
requested_by: context[:user],
-
draft_file_id: pdf_file.id,
-
file_name: file_name,
-
variable_values: variable_values,
-
source: context[:request],
-
employee: context[:employee]
-
)
-
-
# Initialize signatures
-
generated_doc.initialize_signatures!
-
-
generated_doc
-
end
-
-
class GenerationError < StandardError; end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class PdfSignatureService
-
def initialize(generated_document)
-
@generated_document = generated_document
-
end
-
-
# Apply all collected signatures to the PDF
-
# Always reads from draft_file_id to avoid double-applying signatures
-
def apply_all_signatures!
-
return unless @generated_document.all_required_signed?
-
-
# Always read from draft (original PDF without signatures)
-
# This prevents double-application when apply_signature_to_pdf! was called earlier
-
pdf_content = read_draft_pdf
-
raise SignatureError, "No se pudo leer el PDF draft" unless pdf_content
-
-
# Create working files
-
input_pdf = Tempfile.new(["input", ".pdf"])
-
input_pdf.binmode
-
input_pdf.write(pdf_content)
-
input_pdf.rewind
-
-
begin
-
# Load the PDF
-
pdf = CombinePDF.load(input_pdf.path)
-
pages = pdf.pages
-
total_pages = pages.count
-
-
# Get actual PDF page height
-
actual_page_height = pages.first&.mediabox&.dig(3) || 792.0
-
-
# Get template's stored preview page height (used when coordinates were captured)
-
template = @generated_document.template
-
preview_page_height = template&.preview_page_height || 792.0
-
-
# Calculate scale factor if template preview height differs from actual PDF
-
# This handles cases where the preview was at a different scale than the actual PDF
-
scale_factor = actual_page_height / preview_page_height
-
-
Rails.logger.info "PDF rendering: actual_page_height=#{actual_page_height}, preview_page_height=#{preview_page_height}, scale_factor=#{scale_factor}"
-
-
# Apply each signature to the correct page
-
@generated_document.signed_signatories.each do |sig_entry|
-
signatory = find_signatory(sig_entry["signatory_id"])
-
next unless signatory
-
-
# Get stored coordinates (captured at preview_page_height scale)
-
stored_y = signatory.y_position || 0
-
-
# Scale coordinates to actual PDF dimensions
-
absolute_y = stored_y * scale_factor
-
-
# Calculate which page this Y coordinate falls on (0-indexed)
-
calculated_page_index = (absolute_y / actual_page_height).floor
-
-
# Clamp to valid range
-
page_index = [[calculated_page_index, 0].max, total_pages - 1].min
-
target_page = pages[page_index]
-
-
Rails.logger.info "Signature #{signatory.label}: stored_y=#{stored_y}, absolute_y=#{absolute_y}, page_height=#{actual_page_height}, calculated_page=#{calculated_page_index + 1}, actual_page=#{page_index + 1}"
-
-
apply_signature_to_page(target_page, sig_entry, page_index, actual_page_height)
-
end
-
-
# Save the final PDF
-
output_pdf = Tempfile.new(["signed", ".pdf"])
-
pdf.save(output_pdf.path)
-
-
# Store in GridFS
-
store_final_pdf(File.read(output_pdf.path))
-
ensure
-
input_pdf.close
-
input_pdf.unlink
-
output_pdf&.close
-
output_pdf&.unlink
-
end
-
end
-
-
# Apply a single signature when it's added
-
def apply_single_signature!(signature, signatory)
-
# For now, signatures are collected and applied all at once
-
# This method could be used for real-time preview
-
true
-
end
-
-
private
-
-
def apply_signature_to_page(page, sig_entry, page_index, page_height)
-
# Get signature data
-
signature = find_signature(sig_entry["signature_id"])
-
return unless signature
-
-
signatory = find_signatory(sig_entry["signatory_id"])
-
return unless signatory
-
-
# Get signature as PNG image
-
image_data = get_signature_image(signature)
-
return unless image_data
-
-
# Log signature placement for debugging
-
Rails.logger.info "Applying signature for #{signatory.label} to page #{page_index + 1} at position (#{signatory.x_position}, #{signatory.y_position})"
-
-
# Create signature overlay using Prawn
-
overlay_pdf = create_signature_overlay(
-
image_data: image_data,
-
signatory: signatory,
-
sig_entry: sig_entry,
-
page_width: page.mediabox[2],
-
page_height: page_height,
-
target_page_index: page_index
-
)
-
-
# Merge overlay onto page
-
overlay = CombinePDF.parse(overlay_pdf)
-
page << overlay.pages.first
-
end
-
-
def find_signature(signature_uuid)
-
return nil if signature_uuid.blank?
-
-
Identity::UserSignature.where(uuid: signature_uuid).first
-
end
-
-
def find_signatory(signatory_uuid)
-
return nil if signatory_uuid.blank?
-
return nil unless @generated_document.template
-
-
@generated_document.template.signatories.where(uuid: signatory_uuid).first
-
end
-
-
def get_signature_image(signature)
-
renderer = SignatureRendererService.new(signature)
-
tempfile = renderer.to_tempfile
-
-
begin
-
File.read(tempfile.path)
-
ensure
-
tempfile.close
-
tempfile.unlink
-
end
-
end
-
-
def create_signature_overlay(image_data:, signatory:, sig_entry:, page_width:, page_height:, target_page_index:)
-
# Get position from signatory config
-
box = signatory.signature_box
-
x = box[:x]
-
absolute_y = box[:y] # y from top of ENTIRE document (absolute coordinate)
-
width = box[:width]
-
height = box[:height]
-
show_label = box[:show_label].nil? ? true : box[:show_label]
-
show_signer_name = box[:show_signer_name] || false
-
date_position = box[:date_position] || "right"
-
-
# Convert absolute Y to per-page Y coordinate
-
# absolute_y is the distance from top of the entire document
-
# We need to convert it to distance from top of the specific page
-
y = absolute_y - (target_page_index * page_height)
-
-
Rails.logger.info "Signature placement: absolute_y=#{absolute_y}, page_index=#{target_page_index}, page_height=#{page_height}, y_on_page=#{y}"
-
-
# Create temp image file
-
img_file = Tempfile.new(["sig", ".png"])
-
img_file.binmode
-
img_file.write(image_data)
-
img_file.rewind
-
-
begin
-
pdf = Prawn::Document.new(
-
page_size: [page_width, page_height],
-
margin: 0,
-
skip_page_creation: false
-
)
-
-
# Calculate position from bottom (Prawn uses bottom-left origin)
-
# y_from_top = 700 means the signature box TOP is 700pt from page top
-
# So bottom of signature box is at: page_height - y - height
-
y_from_bottom = page_height - y - height
-
-
# Calculate text space needed
-
text_lines = 0
-
text_lines += 1 if show_label
-
text_lines += 1 if show_signer_name
-
text_lines += 1 if date_position != "none"
-
text_space = text_lines * 12
-
-
# Draw signature image at absolute position
-
pdf.image img_file.path, at: [x, y_from_bottom + height], fit: [width, height - text_space]
-
-
# Add optional elements below signature
-
current_y = y_from_bottom + text_space - 5
-
-
if show_label
-
pdf.draw_text signatory.label, at: [x + (width / 2) - 40, current_y], size: 8
-
current_y -= 12
-
end
-
-
if show_signer_name && sig_entry["signed_by_name"].present?
-
pdf.fill_color "333333"
-
pdf.draw_text "Firmado por: #{sig_entry['signed_by_name']}", at: [x + (width / 2) - 50, current_y], size: 7
-
pdf.fill_color "000000"
-
current_y -= 12
-
end
-
-
if date_position != "none"
-
pdf.fill_color "666666"
-
pdf.draw_text "Firmado: #{format_date(sig_entry['signed_at'])}", at: [x + (width / 2) - 50, current_y], size: 7
-
pdf.fill_color "000000"
-
end
-
-
pdf.render
-
ensure
-
img_file.close
-
img_file.unlink
-
end
-
end
-
-
# Read the original draft PDF (without any signatures applied)
-
# Uses original_draft_file_id if available, falls back to draft_file_id
-
def read_draft_pdf
-
# Prefer original_draft_file_id (clean PDF without any signatures)
-
file_id = @generated_document.original_draft_file_id || @generated_document.draft_file_id
-
return nil unless file_id
-
-
file = Mongoid::GridFs.get(file_id)
-
file.data
-
rescue StandardError => e
-
Rails.logger.error "Error reading draft PDF: #{e.message}"
-
nil
-
end
-
-
def store_final_pdf(pdf_content)
-
file_name = @generated_document.file_name.sub(".pdf", "-firmado.pdf")
-
pdf_file = Mongoid::GridFs.put(
-
StringIO.new(pdf_content),
-
filename: file_name,
-
content_type: "application/pdf"
-
)
-
-
@generated_document.update!(final_file_id: pdf_file.id)
-
end
-
-
def format_date(date_string)
-
return "" unless date_string
-
-
Time.parse(date_string).strftime("%d/%m/%Y %H:%M")
-
rescue StandardError
-
date_string.to_s
-
end
-
-
class SignatureError < StandardError; end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "zip"
-
require "nokogiri"
-
-
module Templates
-
# Robust document generator that handles fragmented Word XML runs
-
# and preserves formatting when replacing variables
-
class RobustDocumentGeneratorService
-
attr_reader :template, :context, :variable_values, :replacement_log
-
-
WORD_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
-
VARIABLE_PATTERN = /\{\{([^}]+)\}\}/
-
-
def initialize(template, context)
-
@template = template
-
@context = context
-
@variable_values = {}
-
@replacement_log = []
-
end
-
-
# Generate a document from template
-
def generate!
-
validate_template!
-
resolve_variables!
-
validate_required_variables!
-
log_variables_to_replace
-
generate_document!
-
end
-
-
# Validate variables without generating - returns hash with missing info
-
def validate_variables
-
validate_template!
-
resolve_variables!
-
find_missing_variables
-
end
-
-
private
-
-
def validate_template!
-
raise GenerationError, "Template no activo" unless template.active?
-
raise GenerationError, "Template sin archivo" unless template.file_id
-
end
-
-
def resolve_variables!
-
resolver = VariableResolverService.new(context)
-
@variable_values = resolver.resolve_for_template(template)
-
end
-
-
def validate_required_variables!
-
missing = find_missing_variables
-
return if missing[:variables].empty?
-
-
error_message = build_missing_variables_error(missing)
-
raise MissingVariablesError.new(error_message, missing)
-
end
-
-
def find_missing_variables
-
missing_vars = []
-
-
template.variables.each do |var_name|
-
path = template.variable_mappings[var_name]
-
value = @variable_values[var_name]
-
-
# Variable sin mapeo
-
if path.nil? || path.empty?
-
missing_vars << {
-
variable: var_name,
-
path: nil,
-
reason: "sin_mapeo",
-
source: nil,
-
field: nil
-
}
-
next
-
end
-
-
# Variable con valor vacío o nulo
-
if value.nil? || value.to_s.strip.empty?
-
parts = path.split(".")
-
source = parts.first
-
field = parts[1..].join(".")
-
-
missing_vars << {
-
variable: var_name,
-
path: path,
-
reason: "sin_valor",
-
source: source,
-
field: field,
-
field_label: humanize_field(field)
-
}
-
end
-
end
-
-
{
-
variables: missing_vars,
-
by_source: group_by_source(missing_vars),
-
employee_id: context[:employee]&.uuid,
-
employee_name: context[:employee]&.full_name
-
}
-
end
-
-
def group_by_source(missing_vars)
-
missing_vars.group_by { |v| v[:source] }.transform_values do |vars|
-
vars.map { |v| { variable: v[:variable], field: v[:field], field_label: v[:field_label] } }
-
end
-
end
-
-
def humanize_field(field)
-
translations = {
-
"full_name" => "Nombre Completo",
-
"identification_number" => "Número de Identificación",
-
"identification_type" => "Tipo de Documento",
-
"job_title" => "Cargo",
-
"department" => "Departamento",
-
"hire_date" => "Fecha de Ingreso",
-
"salary" => "Salario",
-
"food_allowance" => "Auxilio de Alimentación",
-
"transport_allowance" => "Auxilio de Transporte",
-
"contract_type" => "Tipo de Contrato",
-
"contract_start_date" => "Fecha Inicio Contrato",
-
"contract_end_date" => "Fecha Fin Contrato",
-
"address" => "Dirección",
-
"phone" => "Teléfono",
-
"email" => "Correo Electrónico",
-
"place_of_birth" => "Lugar de Nacimiento",
-
"nationality" => "Nacionalidad",
-
"date_of_birth" => "Fecha de Nacimiento",
-
"name" => "Nombre",
-
"tax_id" => "NIT",
-
"nit" => "NIT",
-
"city" => "Ciudad"
-
}
-
translations[field] || field.humanize
-
end
-
-
def build_missing_variables_error(missing)
-
vars = missing[:variables]
-
-
by_source = vars.group_by { |v| v[:source] }
-
-
messages = []
-
-
if by_source["employee"]&.any?
-
fields = by_source["employee"].map { |v| v[:field_label] || v[:field] }.join(", ")
-
messages << "Datos del empleado faltantes: #{fields}"
-
end
-
-
if by_source["organization"]&.any?
-
fields = by_source["organization"].map { |v| v[:field_label] || v[:field] }.join(", ")
-
messages << "Datos de la organización faltantes: #{fields}"
-
end
-
-
if by_source["third_party"]&.any?
-
fields = by_source["third_party"].map { |v| v[:field_label] || v[:field] }.join(", ")
-
messages << "Datos del tercero faltantes: #{fields}"
-
end
-
-
if by_source["contract"]&.any?
-
fields = by_source["contract"].map { |v| v[:field_label] || v[:field] }.join(", ")
-
messages << "Datos del contrato faltantes: #{fields}"
-
end
-
-
if by_source[nil]&.any?
-
vars_list = by_source[nil].map { |v| v[:variable] }.join(", ")
-
messages << "Variables sin mapeo: #{vars_list}"
-
end
-
-
if by_source["custom"]&.any?
-
vars_list = by_source["custom"].map { |v| v[:variable] }.join(", ")
-
messages << "Variables personalizadas sin valor: #{vars_list}"
-
end
-
-
messages.join(". ")
-
end
-
-
def log_variables_to_replace
-
Rails.logger.info "=== Document Generation: Variables to Replace ==="
-
variable_values.each do |name, value|
-
Rails.logger.info " {{#{name}}} => #{value.inspect}"
-
end
-
Rails.logger.info "================================================="
-
end
-
-
def generate_document!
-
template_content = template.file_content
-
raise GenerationError, "No se pudo leer el archivo del template" unless template_content
-
-
# Create temp files
-
input_file = Tempfile.new(["template", ".docx"])
-
input_file.binmode
-
input_file.write(template_content)
-
input_file.close
-
-
output_file = Tempfile.new(["output", ".docx"])
-
output_file.close
-
-
begin
-
# Process the DOCX file (replace variables)
-
process_docx(input_file.path, output_file.path)
-
-
# Log replacement results
-
log_replacement_results
-
-
# Read the processed DOCX content
-
docx_content = File.binread(output_file.path)
-
-
# Try to convert to PDF
-
pdf_content = convert_to_pdf(output_file.path)
-
-
if pdf_content
-
# PDF conversion successful - create complete document
-
create_generated_document(pdf_content, docx_content: nil)
-
else
-
# PDF conversion failed - store DOCX for local sync
-
Rails.logger.warn "PDF conversion failed. Storing DOCX for local sync workflow."
-
create_generated_document_pending_pdf(docx_content)
-
end
-
ensure
-
input_file.unlink
-
output_file.unlink
-
end
-
end
-
-
def process_docx(input_path, output_path)
-
# Copy input to output first
-
FileUtils.cp(input_path, output_path)
-
-
Zip::File.open(output_path) do |zipfile|
-
# Process main document
-
process_xml_part(zipfile, "word/document.xml")
-
-
# Process headers
-
zipfile.glob("word/header*.xml").each do |entry|
-
process_xml_part(zipfile, entry.name)
-
end
-
-
# Process footers
-
zipfile.glob("word/footer*.xml").each do |entry|
-
process_xml_part(zipfile, entry.name)
-
end
-
end
-
end
-
-
def process_xml_part(zipfile, entry_name)
-
entry = zipfile.find_entry(entry_name)
-
return unless entry
-
-
xml_content = entry.get_input_stream.read
-
doc = Nokogiri::XML(xml_content)
-
-
# Process all paragraphs
-
doc.xpath("//w:p", "w" => WORD_NAMESPACE).each do |paragraph|
-
process_paragraph(paragraph)
-
end
-
-
# Write back
-
zipfile.get_output_stream(entry_name) { |f| f.write(doc.to_xml) }
-
end
-
-
def process_paragraph(paragraph)
-
# Get all text runs in this paragraph
-
runs = paragraph.xpath(".//w:r", "w" => WORD_NAMESPACE)
-
return if runs.empty?
-
-
# Collect all text content to find variables
-
full_text = runs.map { |r| get_run_text(r) }.join
-
-
# Find all variables in the combined text
-
variables_found = full_text.scan(VARIABLE_PATTERN).flatten
-
-
return if variables_found.empty?
-
-
# For each variable found, we need to handle the replacement
-
# This is complex because the variable might span multiple runs
-
variables_found.each do |var_name|
-
pattern = "{{#{var_name}}}"
-
-
# Find the matching variable in variable_values using normalized comparison
-
replacement = find_variable_value(var_name)
-
-
if replacement.nil?
-
@replacement_log << { variable: var_name, status: "not_found", reason: "No mapping found" }
-
next
-
end
-
-
# Try to replace in the consolidated text of the paragraph
-
replace_variable_in_paragraph(paragraph, pattern, replacement.to_s)
-
@replacement_log << { variable: var_name, status: "replaced", value: replacement.to_s }
-
end
-
end
-
-
# Find variable value using normalized comparison
-
# This allows matching "NOMBRE DEL TRABAJADOR" with "Nombre del Trabajador"
-
def find_variable_value(var_name)
-
# First try exact match
-
return variable_values[var_name] if variable_values.key?(var_name)
-
-
# Then try normalized comparison
-
normalized_var = normalize_for_comparison(var_name)
-
-
variable_values.each do |key, value|
-
return value if normalize_for_comparison(key) == normalized_var
-
end
-
-
nil
-
end
-
-
# Normalize a string for comparison (lowercase, no accents)
-
def normalize_for_comparison(str)
-
# Remove accents and convert to lowercase
-
str.to_s
-
.unicode_normalize(:nfd)
-
.gsub(/[\u0300-\u036f]/, "")
-
.downcase
-
.strip
-
end
-
-
def get_run_text(run)
-
run.xpath(".//w:t", "w" => WORD_NAMESPACE).map(&:text).join
-
end
-
-
def replace_variable_in_paragraph(paragraph, pattern, replacement)
-
runs = paragraph.xpath(".//w:r", "w" => WORD_NAMESPACE)
-
return if runs.empty?
-
-
# Strategy 1: Try simple replacement in each run
-
runs.each do |run|
-
text_nodes = run.xpath(".//w:t", "w" => WORD_NAMESPACE)
-
text_nodes.each do |text_node|
-
if text_node.text.include?(pattern)
-
text_node.content = text_node.text.gsub(pattern, replacement)
-
return true
-
end
-
end
-
end
-
-
# Strategy 2: Handle fragmented variables across runs
-
# Collect text from all runs and find the variable position
-
full_text = ""
-
run_map = [] # [{run:, text_node:, start:, end:}]
-
-
runs.each do |run|
-
text_nodes = run.xpath(".//w:t", "w" => WORD_NAMESPACE)
-
text_nodes.each do |text_node|
-
start_pos = full_text.length
-
full_text += text_node.text
-
end_pos = full_text.length
-
run_map << { run: run, text_node: text_node, start: start_pos, end: end_pos }
-
end
-
end
-
-
# Find the variable in the full text
-
var_start = full_text.index(pattern)
-
return false unless var_start
-
-
var_end = var_start + pattern.length
-
-
# Find which runs contain the variable
-
affected_nodes = run_map.select do |entry|
-
# Node overlaps with variable position
-
entry[:start] < var_end && entry[:end] > var_start
-
end
-
-
return false if affected_nodes.empty?
-
-
if affected_nodes.length == 1
-
# Variable is in a single node - simple replacement
-
node = affected_nodes.first[:text_node]
-
node.content = node.text.gsub(pattern, replacement)
-
else
-
# Variable spans multiple nodes
-
# Put the replacement in the first node and clear the rest
-
first_node = affected_nodes.first
-
first_node_text = first_node[:text_node].text
-
-
# Calculate what part of the pattern is in the first node
-
pattern_start_in_node = [var_start - first_node[:start], 0].max
-
pattern_end_in_node = [var_end - first_node[:start], first_node_text.length].min
-
-
# Replace in first node
-
new_text = first_node_text[0...pattern_start_in_node] + replacement
-
remaining_text = first_node_text[pattern_end_in_node..]
-
first_node[:text_node].content = new_text + (remaining_text || "")
-
-
# Clear the parts of the variable from subsequent nodes
-
affected_nodes[1..].each do |entry|
-
node_text = entry[:text_node].text
-
node_start = entry[:start]
-
node_end = entry[:end]
-
-
# Calculate what part of this node is part of the variable
-
clear_start = [var_start - node_start, 0].max
-
clear_end = [var_end - node_start, node_text.length].min
-
-
# Keep text before and after the variable part
-
new_content = node_text[0...clear_start].to_s + node_text[clear_end..].to_s
-
entry[:text_node].content = new_content
-
end
-
end
-
-
true
-
end
-
-
def log_replacement_results
-
Rails.logger.info "=== Document Generation: Replacement Results ==="
-
@replacement_log.each do |log|
-
if log[:status] == "replaced"
-
Rails.logger.info " ✓ {{#{log[:variable]}}} => #{log[:value]}"
-
else
-
Rails.logger.warn " ✗ {{#{log[:variable]}}} - #{log[:reason]}"
-
end
-
end
-
Rails.logger.info "================================================"
-
end
-
-
def convert_to_pdf(docx_path)
-
# Priority 1: LibreOffice (local, best quality)
-
if libreoffice_available?
-
result = convert_with_libreoffice(docx_path)
-
return result if result
-
end
-
-
# Priority 2: Gotenberg API (LibreOffice via HTTP, preserves formatting)
-
if gotenberg_available?
-
result = convert_with_gotenberg(docx_path)
-
return result if result
-
end
-
-
# Priority 3: Local PDF sync workflow (for Heroku deployment)
-
# When LibreOffice and Gotenberg are unavailable, store DOCX for local conversion
-
# This preserves formatting by generating PDF locally with LibreOffice
-
# Use: rake db:sync:generate_pending_pdfs
-
Rails.logger.info "LibreOffice/Gotenberg unavailable - using local PDF sync workflow"
-
Rails.logger.info "Document will be created with 'pending' status. Run 'rake db:sync:generate_pending_pdfs' locally to generate PDF with proper formatting."
-
nil
-
end
-
-
def gotenberg_available?
-
ENV["GOTENBERG_URL"].present?
-
end
-
-
def convert_with_gotenberg(docx_path)
-
Rails.logger.info "Converting DOCX to PDF using Gotenberg API..."
-
-
begin
-
require "net/http"
-
require "uri"
-
-
gotenberg_url = ENV["GOTENBERG_URL"].chomp("/")
-
uri = URI.parse("#{gotenberg_url}/forms/libreoffice/convert")
-
-
# Prepare multipart form data
-
boundary = "----GotenbergBoundary#{SecureRandom.hex(8)}"
-
file_content = File.binread(docx_path)
-
file_name = File.basename(docx_path)
-
-
body = build_multipart_body(boundary, file_name, file_content)
-
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = uri.scheme == "https"
-
http.read_timeout = 60
-
http.open_timeout = 30
-
-
request = Net::HTTP::Post.new(uri.request_uri)
-
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
-
request.body = body
-
-
response = http.request(request)
-
-
if response.code == "200"
-
Rails.logger.info "Gotenberg conversion successful (#{response.body.bytesize} bytes)"
-
response.body
-
else
-
Rails.logger.error "Gotenberg conversion failed: #{response.code} - #{response.body}"
-
nil
-
end
-
rescue StandardError => e
-
Rails.logger.error "Gotenberg conversion error: #{e.message}"
-
Rails.logger.error e.backtrace.first(3).join("\n")
-
nil
-
end
-
end
-
-
def build_multipart_body(boundary, file_name, file_content)
-
body = ""
-
body << "--#{boundary}\r\n"
-
body << "Content-Disposition: form-data; name=\"files\"; filename=\"#{file_name}\"\r\n"
-
body << "Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document\r\n"
-
body << "\r\n"
-
body << file_content
-
body << "\r\n"
-
body << "--#{boundary}--\r\n"
-
body
-
end
-
-
def pandoc_available?
-
@pandoc_path ||= `which pandoc 2>/dev/null`.strip.presence
-
@pandoc_path ||= "/app/vendor/pandoc/bin/pandoc" if File.exist?("/app/vendor/pandoc/bin/pandoc")
-
@pandoc_path.present?
-
end
-
-
def convert_with_pandoc_wkhtmltopdf(docx_path)
-
Rails.logger.info "Converting DOCX to PDF using Pandoc + wkhtmltopdf..."
-
-
begin
-
# Step 1: Convert DOCX to HTML using Pandoc (using shell command directly)
-
html_output = Tempfile.new(["pandoc_output", ".html"])
-
html_output.close
-
-
pandoc_cmd = "pandoc -f docx -t html5 --standalone \"#{docx_path}\" -o \"#{html_output.path}\" 2>&1"
-
Rails.logger.info "Running: #{pandoc_cmd}"
-
result = `#{pandoc_cmd}`
-
-
unless $?.success?
-
Rails.logger.error "Pandoc failed: #{result}"
-
html_output.unlink
-
return nil
-
end
-
-
html_content = File.read(html_output.path)
-
html_output.unlink
-
Rails.logger.info "Pandoc DOCX->HTML conversion successful (#{html_content.bytesize} bytes)"
-
-
# Inject additional styles into the pandoc-generated HTML
-
styled_html = inject_pdf_styles(html_content)
-
-
# Step 2: Convert HTML to PDF using wkhtmltopdf
-
pdf_content = WickedPdf.new.pdf_from_string(
-
styled_html,
-
page_size: "Letter",
-
margin: { top: 20, bottom: 20, left: 20, right: 20 },
-
encoding: "UTF-8"
-
)
-
-
Rails.logger.info "wkhtmltopdf HTML->PDF conversion successful (#{pdf_content.bytesize} bytes)"
-
pdf_content
-
rescue StandardError => e
-
Rails.logger.error "Pandoc + wkhtmltopdf conversion failed: #{e.message}"
-
Rails.logger.error e.backtrace.first(5).join("\n")
-
nil
-
end
-
end
-
-
def inject_pdf_styles(html_content)
-
# Inject additional CSS styles into pandoc-generated HTML
-
additional_styles = <<~CSS
-
<style>
-
body {
-
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
-
font-size: 11pt;
-
line-height: 1.4;
-
color: #333;
-
max-width: 100%;
-
}
-
h1, h2, h3, h4, h5, h6 {
-
color: #222;
-
margin-top: 0.8em;
-
margin-bottom: 0.4em;
-
}
-
p {
-
margin: 0.4em 0;
-
text-align: justify;
-
}
-
table {
-
border-collapse: collapse;
-
width: 100%;
-
margin: 0.8em 0;
-
}
-
th, td {
-
border: 1px solid #999;
-
padding: 6px;
-
text-align: left;
-
}
-
th {
-
background-color: #f0f0f0;
-
font-weight: bold;
-
}
-
</style>
-
CSS
-
-
# Insert styles before </head>
-
if html_content.include?("</head>")
-
html_content.sub("</head>", "#{additional_styles}</head>")
-
else
-
# If no head tag, wrap the content
-
<<~HTML
-
<!DOCTYPE html>
-
<html>
-
<head>
-
<meta charset="UTF-8">
-
#{additional_styles}
-
</head>
-
<body>
-
#{html_content}
-
</body>
-
</html>
-
HTML
-
end
-
end
-
-
def libreoffice_available?
-
# Check common LibreOffice paths on macOS, Linux, and Heroku
-
paths_to_check = [
-
"/app/.apt/usr/bin/soffice", # Heroku apt buildpack path
-
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
-
"/usr/local/bin/soffice",
-
"/usr/bin/soffice",
-
"/usr/bin/libreoffice"
-
]
-
-
@libreoffice_path = paths_to_check.find { |p| File.exist?(p) }
-
@libreoffice_path ||= `which soffice 2>/dev/null`.strip.presence
-
@libreoffice_path ||= `which libreoffice 2>/dev/null`.strip.presence
-
-
Rails.logger.info "LibreOffice path: #{@libreoffice_path || 'NOT FOUND'}"
-
@libreoffice_path.present?
-
end
-
-
def convert_with_libreoffice(docx_path)
-
output_dir = Dir.mktmpdir
-
user_profile = Dir.mktmpdir("lo_profile")
-
-
begin
-
# Set environment for Heroku apt buildpack
-
lib_path = "/app/.apt/usr/lib/libreoffice/program:/app/.apt/usr/lib/x86_64-linux-gnu"
-
-
# Additional environment variables to fix LibreOffice issues on Heroku
-
env_vars = {
-
"LD_LIBRARY_PATH" => "#{lib_path}:#{ENV['LD_LIBRARY_PATH']}",
-
"HOME" => "/tmp",
-
"FONTCONFIG_PATH" => "/etc/fonts",
-
"SAL_DISABLE_SYNCHRONOUS_PRINTER_DETECTION" => "1",
-
"SAL_DISABLE_COMPONENTITHREADING" => "1",
-
"SAL_USE_VCLPLUGIN" => "svp",
-
"DISPLAY" => "",
-
"URE_BOOTSTRAP" => "file:///app/.apt/usr/lib/libreoffice/program/fundamentalrc"
-
}
-
-
env_string = env_vars.map { |k, v| "#{k}=#{v}" }.join(" ")
-
-
# Use -env:UserInstallation to avoid profile issues
-
user_install = "-env:UserInstallation=file://#{user_profile}"
-
-
cmd = "#{env_string} \"#{@libreoffice_path}\" --headless --nologo --nofirststartwizard --norestore #{user_install} --convert-to pdf --outdir \"#{output_dir}\" \"#{docx_path}\" 2>&1"
-
Rails.logger.info "Running LibreOffice: #{cmd}"
-
result = `#{cmd}`
-
Rails.logger.info "LibreOffice conversion result: #{result}"
-
-
pdf_files = Dir.glob(File.join(output_dir, "*.pdf"))
-
if pdf_files.empty?
-
Rails.logger.error "LibreOffice conversion failed"
-
return nil
-
end
-
-
File.binread(pdf_files.first)
-
ensure
-
FileUtils.rm_rf(output_dir)
-
FileUtils.rm_rf(user_profile)
-
end
-
end
-
-
def convert_using_preview_with_overlay(_docx_path)
-
require "hexapdf"
-
require "combine_pdf"
-
-
preview_content = template.preview_content
-
return nil unless preview_content
-
-
Rails.logger.info "Attempting PDF text replacement using HexaPDF..."
-
-
begin
-
# Try to replace variables directly in the PDF using HexaPDF's text search
-
doc = HexaPDF::Document.new(io: StringIO.new(preview_content))
-
replacements_made = 0
-
-
variable_values.each do |var_name, value|
-
# Try different placeholder formats
-
placeholders = [
-
"{{#{var_name}}}",
-
"{{ #{var_name} }}",
-
"{{#{var_name.upcase}}}",
-
"{{#{var_name.downcase}}}"
-
]
-
-
doc.pages.each do |page|
-
# Get the page's content stream
-
contents = page.contents
-
next unless contents
-
-
# Decode the content stream to get raw data
-
data = contents.stream rescue nil
-
next unless data
-
-
data_str = data.to_s.force_encoding("UTF-8") rescue data.to_s
-
-
placeholders.each do |placeholder|
-
if data_str.include?(placeholder)
-
data_str.gsub!(placeholder, value.to_s)
-
replacements_made += 1
-
Rails.logger.info " Replaced '#{placeholder}' with '#{value}'"
-
end
-
end
-
-
# Update the content stream if changes were made
-
if replacements_made > 0
-
contents.stream = data_str
-
end
-
end
-
end
-
-
if replacements_made > 0
-
Rails.logger.info "HexaPDF: Made #{replacements_made} replacements successfully"
-
output = StringIO.new
-
doc.write(output)
-
return output.string
-
else
-
Rails.logger.info "HexaPDF: No direct replacements possible, using data summary page"
-
end
-
rescue StandardError => e
-
Rails.logger.warn "HexaPDF replacement failed: #{e.message}, falling back to data summary"
-
end
-
-
# Fallback: Use original preview with data summary page
-
convert_using_stored_preview
-
end
-
-
def convert_using_stored_preview
-
require "combine_pdf"
-
-
# Get the stored PDF preview (has original formatting but with placeholders)
-
preview_content = template.preview_content
-
raise GenerationError, "No se pudo leer el PDF preview" unless preview_content
-
-
# Parse the preview PDF
-
base_pdf = CombinePDF.parse(preview_content)
-
-
# Create a data summary page with all variable values
-
data_page_pdf = create_data_summary_page(base_pdf)
-
-
# Add data summary as the first page
-
if data_page_pdf
-
data_pages = CombinePDF.parse(data_page_pdf)
-
combined = CombinePDF.new
-
data_pages.pages.each { |page| combined << page }
-
base_pdf.pages.each { |page| combined << page }
-
return combined.to_pdf
-
end
-
-
base_pdf.to_pdf
-
end
-
-
def create_data_summary_page(base_pdf)
-
return nil if variable_values.empty?
-
-
# Get page dimensions from first page
-
first_page = base_pdf.pages.first
-
page_width = first_page.mediabox[2] || 612
-
page_height = first_page.mediabox[3] || 792
-
-
employee = context[:employee]
-
org = context[:organization]
-
-
Prawn::Document.new(
-
page_size: [page_width, page_height],
-
margin: 40
-
) do |pdf|
-
# Header
-
pdf.text "DATOS DEL DOCUMENTO", size: 16, style: :bold, align: :center
-
pdf.move_down 5
-
pdf.text "Generado: #{Time.current.strftime('%d/%m/%Y %H:%M')}", size: 9, align: :center, color: "666666"
-
pdf.move_down 20
-
-
# Employee info box
-
if employee
-
pdf.text "DATOS DEL EMPLEADO", size: 12, style: :bold
-
pdf.stroke_horizontal_rule
-
pdf.move_down 10
-
-
employee_data = [
-
["Nombre:", employee.full_name],
-
["Identificacion:", "#{employee.identification_type} #{employee.identification_number}"],
-
["Cargo:", employee.job_title],
-
["Departamento:", employee.department],
-
["Fecha de Ingreso:", employee.hire_date&.strftime("%d/%m/%Y")],
-
["Tipo de Contrato:", format_contract_type(employee.contract_type)],
-
["Salario:", format_currency(employee.salary)]
-
].reject { |_, v| v.blank? }
-
-
pdf.table(employee_data, width: pdf.bounds.width, cell_style: { size: 10, padding: 5 }) do |t|
-
t.columns(0).font_style = :bold
-
t.columns(0).width = 150
-
end
-
pdf.move_down 20
-
end
-
-
# Variable values
-
pdf.text "VARIABLES DEL DOCUMENTO", size: 12, style: :bold
-
pdf.stroke_horizontal_rule
-
pdf.move_down 10
-
-
var_data = variable_values.map { |name, value| [name, value.to_s] }
-
unless var_data.empty?
-
pdf.table(var_data, width: pdf.bounds.width, cell_style: { size: 9, padding: 4 }) do |t|
-
t.columns(0).font_style = :bold
-
t.columns(0).width = 180
-
end
-
end
-
-
# Footer note
-
pdf.move_down 30
-
pdf.text "NOTA: Los datos anteriores corresponden a las variables reemplazadas en el documento.",
-
size: 8, color: "666666", align: :center
-
pdf.text "El formato original del documento se muestra en las paginas siguientes.",
-
size: 8, color: "666666", align: :center
-
end.render
-
rescue => e
-
Rails.logger.error "Error creating data summary page: #{e.message}"
-
nil
-
end
-
-
def generate_basic_prawn_pdf(docx_path)
-
doc = Docx::Document.open(docx_path)
-
-
Prawn::Document.new(page_size: "LETTER", margin: 50) do |pdf|
-
setup_fonts(pdf)
-
-
doc.paragraphs.each do |para|
-
text = para.text.strip
-
next if text.empty?
-
-
render_paragraph(pdf, para, text)
-
end
-
-
doc.tables.each do |table|
-
render_table(pdf, table)
-
end
-
end.render
-
end
-
-
def setup_fonts(pdf)
-
# Try to use system fonts or fallback
-
font_path = Rails.root.join("app/assets/fonts")
-
-
if File.exist?(font_path.join("DejaVuSans.ttf"))
-
pdf.font_families.update(
-
"DejaVu" => {
-
normal: font_path.join("DejaVuSans.ttf").to_s,
-
bold: font_path.join("DejaVuSans-Bold.ttf").to_s,
-
italic: font_path.join("DejaVuSans-Oblique.ttf").to_s
-
}
-
)
-
pdf.font("DejaVu")
-
end
-
rescue StandardError => e
-
Rails.logger.warn "Could not load custom fonts: #{e.message}"
-
end
-
-
def render_paragraph(pdf, para, text)
-
# Detect heading style
-
style_name = para.node.at_xpath(".//w:pStyle/@w:val", "w" => WORD_NAMESPACE)&.value || ""
-
-
options = { size: 11, leading: 4 }
-
-
if style_name.downcase.include?("heading") || style_name.downcase.include?("titulo")
-
options[:size] = 14
-
options[:style] = :bold
-
pdf.move_down 10
-
elsif style_name.downcase.include?("title")
-
options[:size] = 18
-
options[:style] = :bold
-
pdf.move_down 15
-
end
-
-
pdf.text text, options
-
pdf.move_down 6
-
rescue Prawn::Errors::IncompatibleStringEncoding
-
# Handle encoding issues
-
pdf.text text.encode("UTF-8", invalid: :replace, undef: :replace), options
-
pdf.move_down 6
-
end
-
-
def render_table(pdf, table)
-
table_data = table.rows.map do |row|
-
row.cells.map do |cell|
-
cell.paragraphs.map(&:text).join("\n")
-
end
-
end
-
-
return if table_data.empty? || table_data.all?(&:empty?)
-
-
pdf.move_down 10
-
pdf.table(table_data, width: pdf.bounds.width) do
-
cells.padding = 8
-
cells.border_width = 0.5
-
cells.border_color = "666666"
-
row(0).font_style = :bold
-
row(0).background_color = "EEEEEE"
-
end
-
pdf.move_down 10
-
rescue StandardError => e
-
Rails.logger.warn "Error rendering table: #{e.message}"
-
end
-
-
def format_contract_type(type)
-
return nil if type.blank?
-
-
types = {
-
"indefinite" => "Término Indefinido",
-
"fixed_term" => "Término Fijo",
-
"work_or_labor" => "Obra o Labor",
-
"temporary" => "Temporal",
-
"apprenticeship" => "Aprendizaje"
-
}
-
types[type.to_s] || type.to_s.humanize
-
end
-
-
def format_currency(amount)
-
return nil if amount.blank?
-
-
"$#{number_with_delimiter(amount.to_i)}"
-
end
-
-
def number_with_delimiter(number, delimiter: ".")
-
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1' + delimiter).reverse
-
end
-
-
def create_generated_document(pdf_content, docx_content: nil)
-
file_name = "#{template.name.parameterize}-#{Time.current.strftime('%Y%m%d%H%M%S')}.pdf"
-
-
pdf_file = Mongoid::GridFs.put(
-
StringIO.new(pdf_content),
-
filename: file_name,
-
content_type: "application/pdf"
-
)
-
-
generated_doc = GeneratedDocument.create!(
-
name: file_name,
-
template: template,
-
organization: context[:organization],
-
requested_by: context[:user],
-
draft_file_id: pdf_file.id,
-
file_name: file_name,
-
variable_values: variable_values,
-
source: context[:request],
-
employee: context[:employee],
-
pdf_generation_status: "completed"
-
)
-
-
generated_doc.initialize_signatures!
-
generated_doc
-
end
-
-
def create_generated_document_pending_pdf(docx_content)
-
file_name = "#{template.name.parameterize}-#{Time.current.strftime('%Y%m%d%H%M%S')}"
-
-
# Store DOCX in GridFS for later local conversion
-
docx_file = Mongoid::GridFs.put(
-
StringIO.new(docx_content),
-
filename: "#{file_name}.docx",
-
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-
)
-
-
generated_doc = GeneratedDocument.create!(
-
name: "#{file_name}.pdf",
-
template: template,
-
organization: context[:organization],
-
requested_by: context[:user],
-
docx_file_id: docx_file.id,
-
file_name: "#{file_name}.pdf",
-
variable_values: variable_values,
-
source: context[:request],
-
employee: context[:employee],
-
pdf_generation_status: "pending"
-
)
-
-
Rails.logger.info "Document created with pending PDF generation: #{generated_doc.uuid}"
-
generated_doc
-
end
-
-
class GenerationError < StandardError; end
-
-
# Custom error for missing variables with detailed info
-
class MissingVariablesError < StandardError
-
attr_reader :missing_data
-
-
def initialize(message, missing_data)
-
super(message)
-
@missing_data = missing_data
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class SignatureRendererService
-
FONT_PATH_MAP = {
-
"Allura" => "Allura-Regular",
-
"Dancing Script" => "DancingScript-Regular",
-
"Great Vibes" => "GreatVibes-Regular",
-
"Pacifico" => "Pacifico-Regular",
-
"Sacramento" => "Sacramento-Regular"
-
}.freeze
-
-
def initialize(signature)
-
@signature = signature
-
end
-
-
# Render styled signature as base64 PNG
-
def render_styled
-
return @signature.image_data if @signature.drawn?
-
-
text = @signature.styled_text
-
font = FONT_PATH_MAP[@signature.font_family] || "Helvetica"
-
color = @signature.font_color&.delete("#") || "000000"
-
size = @signature.font_size || 48
-
-
# Create signature image using MiniMagick
-
image = MiniMagick::Image.open(transparent_base_image)
-
-
image.combine_options do |c|
-
c.font font_path(font)
-
c.pointsize size.to_s
-
c.fill "##{color}"
-
c.gravity "Center"
-
c.draw "text 0,0 '#{escape_text(text)}'"
-
end
-
-
# Trim whitespace and add padding
-
image.trim
-
image.border "10x10"
-
image.bordercolor "transparent"
-
-
# Convert to base64
-
Base64.strict_encode64(image.to_blob)
-
rescue StandardError => e
-
Rails.logger.error "SignatureRenderer error: #{e.message}"
-
generate_fallback_signature
-
end
-
-
# Render a drawn signature from base64 data
-
def render_drawn
-
@signature.image_data
-
end
-
-
# Render signature to a temporary file for PDF embedding
-
def to_tempfile
-
data = @signature.drawn? ? @signature.image_data : render_styled
-
-
# Remove data URI prefix if present
-
base64_data = data.sub(/^data:image\/\w+;base64,/, "")
-
-
tempfile = Tempfile.new(["signature", ".png"])
-
tempfile.binmode
-
tempfile.write(Base64.decode64(base64_data))
-
tempfile.rewind
-
tempfile
-
end
-
-
private
-
-
def transparent_base_image
-
# Create a transparent 400x150 PNG base using tempfile
-
@base_tempfile = Tempfile.new(["base", ".png"])
-
MiniMagick::Tool::Convert.new do |convert|
-
convert << "-size" << "400x150"
-
convert << "xc:transparent"
-
convert << @base_tempfile.path
-
end
-
@base_tempfile.path
-
end
-
-
def font_path(font_name)
-
# Check for Google Fonts in common locations
-
possible_paths = [
-
Rails.root.join("app", "assets", "fonts", "#{font_name}.ttf"),
-
"/usr/share/fonts/truetype/google-fonts/#{font_name}.ttf",
-
"/Library/Fonts/#{font_name}.ttf",
-
"~/Library/Fonts/#{font_name}.ttf"
-
]
-
-
possible_paths.find { |p| File.exist?(p.to_s) } || font_name
-
end
-
-
def escape_text(text)
-
text.to_s.gsub("'", "\\\\'")
-
end
-
-
def generate_fallback_signature
-
# Generate a simple fallback signature using ImageMagick
-
text = @signature.styled_text || "Signature"
-
color = @signature.font_color&.delete("#") || "000000"
-
-
tempfile = Tempfile.new(["fallback", ".png"])
-
-
MiniMagick::Tool::Convert.new do |convert|
-
convert << "-size" << "400x150"
-
convert << "xc:transparent"
-
convert << "-font" << "Helvetica-Oblique"
-
convert << "-pointsize" << "36"
-
convert << "-fill" << "##{color}"
-
convert << "-gravity" << "Center"
-
convert << "-draw" << "text 0,0 '#{escape_text(text)}'"
-
convert << tempfile.path
-
end
-
-
image = MiniMagick::Image.open(tempfile.path)
-
Base64.strict_encode64(image.to_blob)
-
ensure
-
tempfile&.close
-
tempfile&.unlink
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class TemplateParserService
-
VARIABLE_PATTERN = /\{\{([^}]+)\}\}/
-
-
def initialize(file_content)
-
@file_content = file_content
-
end
-
-
# Extract all {{Variable}} patterns from a Word document
-
def extract_variables
-
return [] unless @file_content
-
-
# Write content to temp file for docx gem
-
tempfile = Tempfile.new(["template", ".docx"])
-
tempfile.binmode
-
tempfile.write(@file_content)
-
tempfile.rewind
-
-
begin
-
doc = Docx::Document.open(tempfile.path)
-
variables = Set.new
-
-
# Extract from paragraphs
-
doc.paragraphs.each do |para|
-
extract_from_text(para.text, variables)
-
end
-
-
# Extract from tables
-
doc.tables.each do |table|
-
table.rows.each do |row|
-
row.cells.each do |cell|
-
cell.paragraphs.each do |para|
-
extract_from_text(para.text, variables)
-
end
-
end
-
end
-
end
-
-
variables.to_a.sort
-
rescue StandardError => e
-
Rails.logger.error "TemplateParser error: #{e.message}"
-
[]
-
ensure
-
tempfile.close
-
tempfile.unlink
-
end
-
end
-
-
private
-
-
def extract_from_text(text, variables)
-
return unless text
-
-
text.scan(VARIABLE_PATTERN).each do |match|
-
variable_name = match[0].strip
-
next if variable_name.blank?
-
-
# Normalize to uppercase without accents for consistent matching
-
normalized_name = VariableNormalizer.normalize(variable_name)
-
variables.add(normalized_name)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
# Normalizes variable names for consistent matching regardless of
-
# case, accents, or special characters
-
class VariableNormalizer
-
# Mapping of accented characters to their base form (lowercase)
-
ACCENT_MAP_LOWER = {
-
"á" => "a", "à" => "a", "ä" => "a", "â" => "a", "ã" => "a",
-
"é" => "e", "è" => "e", "ë" => "e", "ê" => "e",
-
"í" => "i", "ì" => "i", "ï" => "i", "î" => "i",
-
"ó" => "o", "ò" => "o", "ö" => "o", "ô" => "o", "õ" => "o",
-
"ú" => "u", "ù" => "u", "ü" => "u", "û" => "u",
-
"ñ" => "n",
-
"ç" => "c"
-
}.freeze
-
-
# Mapping of accented characters to their base form (uppercase)
-
ACCENT_MAP_UPPER = {
-
"Á" => "A", "À" => "A", "Ä" => "A", "Â" => "A", "Ã" => "A",
-
"É" => "E", "È" => "E", "Ë" => "E", "Ê" => "E",
-
"Í" => "I", "Ì" => "I", "Ï" => "I", "Î" => "I",
-
"Ó" => "O", "Ò" => "O", "Ö" => "O", "Ô" => "O", "Õ" => "O",
-
"Ú" => "U", "Ù" => "U", "Ü" => "U", "Û" => "U",
-
"Ñ" => "N",
-
"Ç" => "C"
-
}.freeze
-
-
# Words that should remain lowercase in Title Case (Spanish)
-
LOWERCASE_WORDS = %w[de del la el los las a en con por para y o u].freeze
-
-
class << self
-
# Normalize a variable name to Title Case without accents
-
# Example: "AUXILIO DE ALIMENTACIÓN" -> "Auxilio de Alimentacion"
-
# @param name [String] The variable name to normalize
-
# @return [String] Normalized variable name in Title Case
-
def normalize(name)
-
return "" if name.blank?
-
-
result = name.to_s.strip
-
-
# Replace accented characters (both cases)
-
ACCENT_MAP_LOWER.each { |accented, base| result = result.gsub(accented, base) }
-
ACCENT_MAP_UPPER.each { |accented, base| result = result.gsub(accented, base) }
-
-
# Split by spaces and other separators, preserving the separators
-
# This handles cases like "Dia/Mes/Ano"
-
parts = result.split(/(\s+|[\/\-])/)
-
-
parts.map.with_index do |part, index|
-
# Skip separators
-
next part if part.match?(/^[\s\/\-]+$/)
-
-
word = part.downcase
-
-
# Find the first real word index (skip separators)
-
first_word_index = parts.index { |p| !p.match?(/^[\s\/\-]+$/) }
-
is_first_word = (index == first_word_index)
-
-
# First word is always capitalized, others check against lowercase list
-
if is_first_word || !LOWERCASE_WORDS.include?(word)
-
word.capitalize
-
else
-
word
-
end
-
end.join
-
end
-
-
# Generate a key-safe version (lowercase, underscores)
-
# @param name [String] The variable name
-
# @return [String] Key-safe string for use in mapping keys
-
def to_key(name)
-
return "" if name.blank?
-
-
result = name.to_s.strip.downcase
-
-
# Replace accented characters
-
ACCENT_MAP_LOWER.each { |accented, base| result = result.gsub(accented, base) }
-
-
result
-
.gsub(/[^a-z0-9]+/, "_")
-
.gsub(/^_+|_+$/, "")
-
end
-
-
# Get the comparison key (for matching, all lowercase without accents)
-
# @param name [String] The variable name
-
# @return [String] Lowercase string for comparison
-
def comparison_key(name)
-
return "" if name.blank?
-
-
result = name.to_s.strip.downcase
-
-
# Replace accented characters
-
ACCENT_MAP_LOWER.each { |accented, base| result = result.gsub(accented, base) }
-
-
# Normalize spaces
-
result.gsub(/\s+/, " ").strip
-
end
-
-
# Check if two variable names are equivalent after normalization
-
# @param name1 [String] First variable name
-
# @param name2 [String] Second variable name
-
# @return [Boolean] True if names match after normalization
-
def equivalent?(name1, name2)
-
comparison_key(name1) == comparison_key(name2)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Templates
-
class VariableResolverService
-
def initialize(context)
-
@employee = context[:employee]
-
@organization = context[:organization]
-
@request = context[:request]
-
@third_party = context[:third_party]
-
@contract = context[:contract]
-
@custom_values = context[:custom_values] || {}
-
end
-
-
# Resolve a single variable path to its value
-
def resolve(variable_path)
-
return @custom_values[variable_path] if @custom_values.key?(variable_path)
-
-
parts = variable_path.split(".")
-
source = parts.first
-
field = parts[1..].join(".")
-
-
case source
-
when "employee"
-
resolve_employee_field(field)
-
when "organization"
-
resolve_organization_field(field)
-
when "request"
-
resolve_request_field(field)
-
when "third_party"
-
resolve_third_party_field(field)
-
when "contract"
-
resolve_contract_field(field)
-
when "system"
-
resolve_system_field(field)
-
when "custom"
-
resolve_custom_field(field)
-
else
-
nil
-
end
-
end
-
-
# Resolve all variables in a mapping hash
-
def resolve_all(variable_mappings)
-
result = {}
-
-
variable_mappings.each do |variable_name, variable_path|
-
result[variable_name] = resolve(variable_path)
-
end
-
-
result
-
end
-
-
# Get all resolved values ready for template substitution
-
def resolve_for_template(template)
-
result = {}
-
-
template.variables.each do |variable_name|
-
path = template.variable_mappings[variable_name]
-
result[variable_name] = path ? resolve(path) : nil
-
end
-
-
result
-
end
-
-
# Validate all template variables and return missing ones
-
# Returns { valid: true/false, missing: [...], resolved: {...} }
-
def validate_for_template(template)
-
resolved = {}
-
missing = []
-
-
template.variables.each do |variable_name|
-
path = template.variable_mappings[variable_name]
-
if path
-
value = resolve(path)
-
if value.present?
-
resolved[variable_name] = value
-
else
-
missing << {
-
variable: variable_name,
-
path: path,
-
source: path.split(".").first,
-
field: path.split(".")[1..].join("."),
-
label: friendly_label_for(path)
-
}
-
end
-
else
-
missing << {
-
variable: variable_name,
-
path: nil,
-
source: "unmapped",
-
field: nil,
-
label: "Variable sin mapear: #{variable_name}"
-
}
-
end
-
end
-
-
{
-
valid: missing.empty?,
-
total_variables: template.variables.size,
-
resolved_count: resolved.size,
-
missing_count: missing.size,
-
missing: missing,
-
resolved: resolved
-
}
-
end
-
-
private
-
-
# Get a friendly label for a variable path
-
def friendly_label_for(path)
-
return "Variable desconocida" unless path
-
-
parts = path.split(".")
-
source = parts.first
-
field = parts[1..].join(".")
-
-
source_labels = {
-
"third_party" => "Tercero",
-
"contract" => "Contrato",
-
"organization" => "Organización",
-
"employee" => "Empleado",
-
"system" => "Sistema"
-
}
-
-
field_labels = {
-
# Third party
-
"legal_rep_name" => "Nombre del representante legal",
-
"legal_rep_id" => "Cédula del representante legal",
-
"legal_rep_email" => "Email del representante legal",
-
"display_name" => "Nombre",
-
"business_name" => "Razón social",
-
"identification_number" => "Número de identificación",
-
"identification_type" => "Tipo de identificación",
-
"address" => "Dirección",
-
"city" => "Ciudad",
-
"phone" => "Teléfono",
-
"email" => "Email",
-
"bank_name" => "Banco",
-
"bank_account_number" => "Número de cuenta",
-
"bank_account_type" => "Tipo de cuenta",
-
# Contract
-
"contract_number" => "Número de contrato",
-
"title" => "Título",
-
"amount" => "Monto",
-
"amount_text" => "Monto en letras",
-
"start_date" => "Fecha de inicio",
-
"end_date" => "Fecha de fin",
-
"description" => "Descripción",
-
"duration_text" => "Duración",
-
# Organization
-
"name" => "Nombre",
-
"tax_id" => "NIT",
-
"nit" => "NIT"
-
}
-
-
source_label = source_labels[source] || source.humanize
-
field_label = field_labels[field] || field.humanize
-
-
"#{source_label}: #{field_label}"
-
end
-
-
def resolve_employee_field(field)
-
return nil unless @employee
-
-
case field
-
when "full_name"
-
@employee.full_name
-
when "first_name"
-
@employee.user&.first_name
-
when "last_name"
-
@employee.user&.last_name
-
when "employee_number"
-
@employee.employee_number
-
when "job_title"
-
@employee.job_title
-
when "department"
-
@employee.department
-
when "hire_date"
-
format_date(@employee.hire_date)
-
when "hire_date_text"
-
format_date_text(@employee.hire_date)
-
when "identification_number"
-
@employee.identification_number
-
when "identification_type"
-
@employee.identification_type
-
when "email"
-
@employee.user&.email
-
when "years_of_service"
-
calculate_years_of_service
-
when "years_of_service_text"
-
format_years_of_service
-
# Compensation fields
-
when "salary"
-
format_currency(@employee.salary)
-
when "salary_text"
-
number_to_words(@employee.salary)
-
when "food_allowance"
-
format_currency(@employee.food_allowance)
-
when "food_allowance_text"
-
number_to_words(@employee.food_allowance)
-
when "transport_allowance"
-
format_currency(@employee.transport_allowance)
-
when "transport_allowance_text"
-
number_to_words(@employee.transport_allowance)
-
when "total_compensation"
-
total = (@employee.salary || 0) + (@employee.food_allowance || 0) + (@employee.transport_allowance || 0)
-
format_currency(total)
-
when "total_compensation_text"
-
total = (@employee.salary || 0) + (@employee.food_allowance || 0) + (@employee.transport_allowance || 0)
-
number_to_words(total)
-
when "payment_frequency"
-
format_payment_frequency(@employee.payment_frequency)
-
when "work_city"
-
@employee.work_city
-
# Contract fields
-
when "contract_type"
-
format_contract_type(@employee.contract_type)
-
when "contract_start_date"
-
format_date(@employee.contract_start_date || @employee.hire_date)
-
when "contract_end_date"
-
format_date(@employee.contract_end_date)
-
when "contract_duration"
-
format_contract_duration
-
when "trial_period_days"
-
@employee.trial_period_days.to_s
-
# Personal data
-
when "address"
-
@employee.address
-
when "phone"
-
@employee.phone
-
when "place_of_birth"
-
@employee.place_of_birth
-
when "nationality"
-
@employee.nationality
-
when "date_of_birth"
-
format_date(@employee.date_of_birth)
-
else
-
@employee.try(field)
-
end
-
end
-
-
def resolve_organization_field(field)
-
return nil unless @organization
-
-
case field
-
when "name"
-
@organization.name
-
when "tax_id", "nit"
-
@organization.settings&.dig("tax_id") || @organization.try(:tax_id)
-
when "address"
-
@organization.settings&.dig("address") || @organization.try(:address)
-
when "city"
-
@organization.settings&.dig("city") || @organization.try(:city)
-
when "phone"
-
@organization.settings&.dig("phone") || @organization.try(:phone)
-
else
-
@organization.settings&.dig(field) || @organization.try(field)
-
end
-
end
-
-
def resolve_third_party_field(field)
-
return nil unless @third_party
-
-
case field
-
when "display_name", "name"
-
@third_party.display_name
-
when "business_name"
-
@third_party.business_name
-
when "trade_name"
-
@third_party.trade_name
-
when "code"
-
@third_party.code
-
when "identification_number"
-
@third_party.identification_number
-
when "identification_type"
-
@third_party.identification_type
-
when "full_identification"
-
"#{@third_party.identification_type} #{@third_party.identification_number}"
-
when "third_party_type", "type"
-
@third_party.type_label
-
when "person_type"
-
@third_party.person_type == "natural" ? "Persona Natural" : "Persona Jurídica"
-
when "email"
-
@third_party.email
-
when "phone"
-
@third_party.phone
-
when "address"
-
@third_party.address
-
when "city"
-
@third_party.city
-
when "country"
-
@third_party.country
-
when "legal_rep_name"
-
@third_party.legal_rep_name
-
when "legal_rep_id"
-
@third_party.legal_rep_id_number
-
when "legal_rep_email"
-
@third_party.legal_rep_email
-
when "bank_name"
-
@third_party.bank_name
-
when "bank_account_type"
-
@third_party.bank_account_type == "savings" ? "Ahorros" : "Corriente"
-
when "bank_account_number"
-
@third_party.bank_account_number
-
when "industry"
-
@third_party.industry
-
else
-
@third_party.try(field)
-
end
-
end
-
-
def resolve_contract_field(field)
-
# If there's a commercial contract, use it
-
if @contract
-
case field
-
when "contract_number", "number"
-
return @contract.contract_number
-
when "title"
-
return @contract.title
-
when "description"
-
return @contract.description
-
when "contract_type", "type"
-
return @contract.type_label
-
when "status"
-
return @contract.status_label
-
when "amount"
-
return format_currency(@contract.amount)
-
when "amount_text"
-
return number_to_words(@contract.amount)
-
when "currency"
-
return @contract.currency
-
when "start_date"
-
return format_date(@contract.start_date)
-
when "start_date_text"
-
return format_date_text(@contract.start_date)
-
when "end_date"
-
return format_date(@contract.end_date)
-
when "end_date_text"
-
return format_date_text(@contract.end_date)
-
when "duration_days"
-
return @contract.duration_days.to_s
-
when "duration_text"
-
return format_contract_duration_from_days(@contract.duration_days)
-
when "payment_terms"
-
return @contract.payment_terms
-
when "payment_frequency"
-
return format_payment_frequency(@contract.payment_frequency)
-
when "approval_level"
-
return @contract.approval_level_label
-
when "approved_at"
-
return format_date(@contract.approved_at)
-
when "approved_at_text"
-
return format_date_text(@contract.approved_at)
-
else
-
return @contract.try(field)
-
end
-
end
-
-
# Fallback to employee contract data if no commercial contract but there's an employee
-
# This handles HR templates that might incorrectly use contract.* mappings
-
return nil unless @employee
-
-
case field
-
when "start_date"
-
format_date(@employee.contract_start_date || @employee.hire_date)
-
when "start_date_text"
-
format_date_text(@employee.contract_start_date || @employee.hire_date)
-
when "end_date"
-
format_date(@employee.contract_end_date)
-
when "end_date_text"
-
format_date_text(@employee.contract_end_date)
-
when "contract_type", "type"
-
format_contract_type(@employee.contract_type)
-
when "duration_text"
-
format_contract_duration
-
else
-
nil
-
end
-
end
-
-
def resolve_request_field(field)
-
return nil unless @request
-
-
case field
-
when "request_number"
-
@request.request_number
-
when "certification_type"
-
format_certification_type
-
when "purpose"
-
format_purpose
-
when "start_date"
-
format_date(@request.try(:start_date))
-
when "end_date"
-
format_date(@request.try(:end_date))
-
when "days_requested"
-
@request.try(:days_requested)
-
when "vacation_type"
-
format_vacation_type
-
when "status"
-
format_status
-
when "submitted_at"
-
format_date(@request.try(:submitted_at))
-
else
-
@request.try(field)
-
end
-
end
-
-
def resolve_system_field(field)
-
case field
-
when "current_date"
-
format_date(Date.current)
-
when "current_date_text"
-
format_date_text(Date.current)
-
when "current_year"
-
Date.current.year.to_s
-
when "current_month"
-
I18n.l(Date.current, format: "%B")
-
when "current_month_year"
-
I18n.l(Date.current, format: "%B de %Y")
-
else
-
nil
-
end
-
end
-
-
# Custom variables that map to employee/organization data with transformations
-
def resolve_custom_field(field)
-
return nil unless @employee
-
-
case field
-
# Auxilio de alimentación en letras (toma de employee.food_allowance)
-
when "auxilio_alimentacion_en_letras_y_pesos", "auxilio_de_alimentacion_en_letras"
-
number_to_words(@employee.food_allowance)
-
# Salario en letras (toma de employee.salary)
-
when "salario_letras_y_pesos", "salario_en_letras"
-
number_to_words(@employee.salary)
-
# Auxilio de transporte en letras
-
when "auxilio_transporte_en_letras_y_pesos", "auxilio_de_transporte_en_letras"
-
number_to_words(@employee.transport_allowance)
-
# Compensación total en letras
-
when "compensacion_total_en_letras"
-
total = (@employee.salary || 0) + (@employee.food_allowance || 0) + (@employee.transport_allowance || 0)
-
number_to_words(total)
-
# Ciudad de labores
-
when "ciudad_labores", "ciudad_de_labores"
-
@employee.work_city
-
# Email del trabajador
-
when "email_trabajador", "correo_trabajador", "email_empleado"
-
@employee.personal_email || @employee.user&.email
-
# Periodicidad de pago
-
when "periodicidad_pago", "frecuencia_pago"
-
format_payment_frequency(@employee.payment_frequency)
-
else
-
nil
-
end
-
end
-
-
def format_date(date)
-
return nil unless date
-
-
date.strftime("%d/%m/%Y")
-
end
-
-
def format_date_text(date)
-
return nil unless date
-
-
I18n.l(date, format: :long, locale: :es)
-
rescue StandardError
-
date.strftime("%d de %B de %Y")
-
end
-
-
def calculate_years_of_service
-
return nil unless @employee&.hire_date
-
-
((Date.current - @employee.hire_date) / 365.25).round(2)
-
end
-
-
def format_years_of_service
-
years = calculate_years_of_service
-
return nil unless years
-
-
complete_years = years.floor
-
months = ((years - complete_years) * 12).round
-
-
if complete_years.zero? && months.zero?
-
"menos de un mes"
-
elsif complete_years.zero?
-
"#{months} #{months == 1 ? 'mes' : 'meses'}"
-
elsif months.zero?
-
"#{complete_years} #{complete_years == 1 ? 'año' : 'años'}"
-
else
-
"#{complete_years} #{complete_years == 1 ? 'año' : 'años'} y #{months} #{months == 1 ? 'mes' : 'meses'}"
-
end
-
end
-
-
def format_certification_type
-
return nil unless @request.respond_to?(:certification_type)
-
-
types = {
-
"employment" => "Certificación Laboral",
-
"salary" => "Certificación de Salario",
-
"position" => "Certificación de Cargo",
-
"full" => "Certificación Completa",
-
"custom" => "Certificación Personalizada"
-
}
-
-
types[@request.certification_type] || @request.certification_type
-
end
-
-
def format_purpose
-
return nil unless @request.respond_to?(:purpose)
-
-
purposes = {
-
"bank" => "Trámite Bancario",
-
"visa" => "Solicitud de Visa",
-
"rental" => "Arrendamiento",
-
"government" => "Trámite Gubernamental",
-
"legal" => "Proceso Legal",
-
"other" => "Otro"
-
}
-
-
purposes[@request.purpose] || @request.purpose
-
end
-
-
def format_vacation_type
-
return nil unless @request.respond_to?(:vacation_type)
-
-
types = {
-
"regular" => "Vacaciones Regulares",
-
"accumulated" => "Vacaciones Acumuladas",
-
"advance" => "Vacaciones Anticipadas",
-
"partial" => "Vacaciones Parciales"
-
}
-
-
types[@request.vacation_type] || @request.vacation_type
-
end
-
-
def format_status
-
return nil unless @request.respond_to?(:status)
-
-
statuses = {
-
"pending" => "Pendiente",
-
"approved" => "Aprobado",
-
"rejected" => "Rechazado",
-
"processing" => "En Proceso",
-
"completed" => "Completado",
-
"cancelled" => "Cancelado"
-
}
-
-
statuses[@request.status] || @request.status
-
end
-
-
def format_currency(amount)
-
return nil unless amount
-
-
# Format as Colombian pesos
-
formatted = amount.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse
-
"$#{formatted}"
-
end
-
-
def number_to_words(amount)
-
return nil unless amount
-
-
# Basic Spanish number to words conversion
-
units = %w[cero uno dos tres cuatro cinco seis siete ocho nueve]
-
teens = %w[diez once doce trece catorce quince dieciseis diecisiete dieciocho diecinueve]
-
tens = %w[veinte treinta cuarenta cincuenta sesenta setenta ochenta noventa]
-
hundreds = ["", "ciento", "doscientos", "trescientos", "cuatrocientos",
-
"quinientos", "seiscientos", "setecientos", "ochocientos", "novecientos"]
-
-
n = amount.to_i
-
-
return "cero pesos" if n.zero?
-
-
parts = []
-
-
# Millions
-
if n >= 1_000_000
-
millions = n / 1_000_000
-
parts << (millions == 1 ? "un millón" : "#{number_to_words_helper(millions, units, teens, tens, hundreds)} millones")
-
n %= 1_000_000
-
end
-
-
# Thousands
-
if n >= 1000
-
thousands = n / 1000
-
parts << (thousands == 1 ? "mil" : "#{number_to_words_helper(thousands, units, teens, tens, hundreds)} mil")
-
n %= 1000
-
end
-
-
# Hundreds and below
-
parts << number_to_words_helper(n, units, teens, tens, hundreds) if n.positive?
-
-
"#{parts.join(' ')} pesos"
-
end
-
-
def number_to_words_helper(n, units, teens, tens, hundreds)
-
return "" if n.zero?
-
return units[n] if n < 10
-
return teens[n - 10] if n < 20
-
return "veinti#{units[n - 20]}" if n < 30
-
return tens[(n / 10) - 2] + (n % 10 > 0 ? " y #{units[n % 10]}" : "") if n < 100
-
return "cien" if n == 100
-
return hundreds[n / 100] + (n % 100 > 0 ? " #{number_to_words_helper(n % 100, units, teens, tens, hundreds)}" : "") if n < 1000
-
-
n.to_s
-
end
-
-
def format_contract_type(contract_type)
-
return nil unless contract_type
-
-
types = {
-
"indefinite" => "Término Indefinido",
-
"fixed_term" => "Término Fijo",
-
"work_or_labor" => "Obra o Labor",
-
"apprentice" => "Aprendizaje"
-
}
-
-
types[contract_type] || contract_type
-
end
-
-
def format_contract_duration
-
return nil unless @employee&.contract_duration_value
-
-
value = @employee.contract_duration_value
-
unit = @employee.contract_duration_unit || "months"
-
-
case unit
-
when "days"
-
"#{value} #{value == 1 ? 'día' : 'días'}"
-
when "weeks"
-
"#{value} #{value == 1 ? 'semana' : 'semanas'}"
-
when "months"
-
format_duration_months(value)
-
when "years"
-
format_duration_years(value)
-
else
-
"#{value} #{unit}"
-
end
-
end
-
-
def format_duration_months(months)
-
if months >= 12
-
years = months / 12
-
remaining_months = months % 12
-
if remaining_months.zero?
-
"#{years} #{years == 1 ? 'año' : 'años'}"
-
else
-
"#{years} #{years == 1 ? 'año' : 'años'} y #{remaining_months} #{remaining_months == 1 ? 'mes' : 'meses'}"
-
end
-
else
-
"#{months} #{months == 1 ? 'mes' : 'meses'}"
-
end
-
end
-
-
def format_duration_years(years)
-
"#{years} #{years == 1 ? 'año' : 'años'}"
-
end
-
-
def format_contract_duration_from_days(days)
-
return nil unless days
-
-
if days >= 365
-
years = days / 365
-
remaining_days = days % 365
-
months = remaining_days / 30
-
if months.positive?
-
"#{years} #{years == 1 ? 'año' : 'años'} y #{months} #{months == 1 ? 'mes' : 'meses'}"
-
else
-
"#{years} #{years == 1 ? 'año' : 'años'}"
-
end
-
elsif days >= 30
-
months = days / 30
-
"#{months} #{months == 1 ? 'mes' : 'meses'}"
-
else
-
"#{days} #{days == 1 ? 'día' : 'días'}"
-
end
-
end
-
-
def format_payment_frequency(frequency)
-
return nil unless frequency
-
-
frequencies = {
-
"monthly" => "Mensual",
-
"bimonthly" => "Bimestral",
-
"quarterly" => "Trimestral",
-
"semiannual" => "Semestral",
-
"annual" => "Anual",
-
"biweekly" => "Quincenal",
-
"weekly" => "Semanal",
-
"one_time" => "Pago Único",
-
"milestone" => "Por Hitos",
-
"upon_delivery" => "Contra Entrega"
-
}
-
-
frequencies[frequency] || frequency
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Workflow
-
# Main service for workflow operations
-
# Provides a high-level API for managing workflows
-
#
-
class WorkflowService
-
attr_reader :user, :organization
-
-
def initialize(user:, organization: nil)
-
@user = user
-
@organization = organization || user.organization
-
end
-
-
# Start a new workflow instance
-
#
-
# @param definition_name [String] Name of the workflow definition
-
# @param document [Content::Document] Document to attach to workflow
-
# @param context_data [Hash] Additional context data
-
# @return [WorkflowInstance]
-
def start_workflow(definition_name, document: nil, context_data: {})
-
definition = find_definition(definition_name)
-
-
instance = definition.create_instance!(
-
document: document,
-
initiated_by: user
-
)
-
-
instance.update!(context_data: context_data) if context_data.present?
-
-
instance
-
end
-
-
# Get a workflow instance
-
#
-
# @param instance_id [String] ID of the instance
-
# @return [WorkflowInstance]
-
def find_instance(instance_id)
-
WorkflowInstance.find(instance_id)
-
end
-
-
# Transition a workflow to a new state
-
#
-
# @param instance [WorkflowInstance] The workflow instance
-
# @param to_state [String] Target state
-
# @param comment [String] Optional comment
-
# @return [WorkflowInstance]
-
def transition(instance, to_state, comment: nil)
-
action = find_action_for_transition(instance, to_state)
-
-
instance.transition_to!(
-
to_state,
-
actor: user,
-
action: action,
-
comment: comment
-
)
-
end
-
-
# Perform a named action on a workflow
-
#
-
# @param instance [WorkflowInstance] The workflow instance
-
# @param action_name [String] Name of the action (e.g., "approve", "reject")
-
# @param comment [String] Optional comment
-
# @return [WorkflowInstance]
-
def perform_action(instance, action_name, comment: nil)
-
to_state = find_state_for_action(instance, action_name)
-
-
unless to_state
-
raise WorkflowError,
-
"Action '#{action_name}' not available from state '#{instance.current_state}'"
-
end
-
-
instance.transition_to!(
-
to_state,
-
actor: user,
-
action: action_name,
-
comment: comment
-
)
-
end
-
-
# Get available actions for current state
-
#
-
# @param instance [WorkflowInstance] The workflow instance
-
# @return [Array<Hash>] Available actions
-
def available_actions(instance)
-
return [] unless instance.active?
-
-
instance.definition.transitions
-
.select { |t| t["from"] == instance.current_state }
-
.map do |t|
-
{
-
action: t["action"],
-
to_state: t["to"],
-
label: action_label(t["action"])
-
}
-
end
-
end
-
-
# Cancel a workflow
-
#
-
# @param instance [WorkflowInstance] The workflow instance
-
# @param reason [String] Cancellation reason
-
# @return [WorkflowInstance]
-
def cancel(instance, reason: nil)
-
instance.cancel!(actor: user, reason: reason)
-
end
-
-
# Claim a task for the current user
-
#
-
# @param task [WorkflowTask] The task to claim
-
# @return [WorkflowTask]
-
def claim_task(task)
-
task.claim!(user)
-
end
-
-
# Release a claimed task
-
#
-
# @param task [WorkflowTask] The task to release
-
# @return [WorkflowTask]
-
def release_task(task)
-
task.release!(user)
-
end
-
-
# Get tasks assigned to current user's roles
-
#
-
# @return [Mongoid::Criteria]
-
def my_tasks
-
role_names = user.roles.pluck(:name)
-
-
WorkflowTask.active
-
.where(organization_id: organization.id)
-
.where(:assigned_role.in => role_names)
-
.or(assignee_id: user.id)
-
.by_priority
-
end
-
-
# Get all active workflows for the organization
-
#
-
# @return [Mongoid::Criteria]
-
def active_workflows
-
WorkflowInstance.active
-
.where(organization_id: organization.id)
-
.order(started_at: :desc)
-
end
-
-
# Get workflows for a specific document
-
#
-
# @param document [Content::Document] The document
-
# @return [Mongoid::Criteria]
-
def workflows_for_document(document)
-
WorkflowInstance.where(document_id: document.id)
-
.order(started_at: :desc)
-
end
-
-
# Get workflow statistics
-
#
-
# @return [Hash]
-
# rubocop:disable Metrics/AbcSize
-
def statistics
-
{
-
active_workflows: WorkflowInstance.active.where(organization_id: organization.id).count,
-
completed_today: WorkflowInstance.completed
-
.where(organization_id: organization.id)
-
.where(:completed_at.gte => Time.current.beginning_of_day)
-
.count,
-
pending_tasks: WorkflowTask.pending.where(organization_id: organization.id).count,
-
overdue_tasks: WorkflowTask.overdue.where(organization_id: organization.id).count,
-
my_pending_tasks: my_tasks.pending.count
-
}
-
end
-
# rubocop:enable Metrics/AbcSize
-
-
private
-
-
def find_definition(name)
-
definition = WorkflowDefinition.find_latest(name)
-
raise WorkflowError, "Workflow definition '#{name}' not found" unless definition
-
-
definition
-
end
-
-
def find_action_for_transition(instance, to_state)
-
transition = instance.definition.transitions.find do |t|
-
t["from"] == instance.current_state && t["to"] == to_state
-
end
-
transition&.dig("action")
-
end
-
-
def find_state_for_action(instance, action_name)
-
transition = instance.definition.transitions.find do |t|
-
t["from"] == instance.current_state && t["action"] == action_name
-
end
-
transition&.dig("to")
-
end
-
-
def action_label(action_name)
-
action_name.to_s.titleize.tr("_", " ")
-
end
-
end
-
end
-
# Run with: rails runner lib/tasks/regenerate_previews.rb
-
require "combine_pdf"
-
-
puts "Regenerating ALL PDF previews locally..."
-
puts "=" * 60
-
-
templates = Templates::Template.where(:file_id.ne => nil).select { |t| t.file_name&.end_with?(".docx") }
-
puts "Found #{templates.count} templates\n\n"
-
-
regenerated = 0
-
templates.each do |template|
-
print " #{template.name}... "
-
-
content = template.file_content
-
unless content
-
puts "No file content, skipped"
-
next
-
end
-
-
begin
-
pdf_content = template.send(:convert_docx_to_pdf_for_dimensions, content)
-
if pdf_content
-
template.store_pdf_preview!(pdf_content)
-
-
# Update dimensions
-
pdf = CombinePDF.parse(pdf_content)
-
if pdf.pages.any?
-
first_page = pdf.pages.first
-
mediabox = first_page.mediabox
-
template.pdf_width = mediabox[2].to_f
-
template.pdf_height = mediabox[3].to_f
-
template.pdf_page_count = pdf.pages.count
-
end
-
-
template.save!
-
regenerated += 1
-
puts "OK (#{pdf.pages.count} pages, #{pdf_content.bytesize} bytes)"
-
else
-
puts "Conversion failed"
-
end
-
rescue => e
-
puts "ERROR: #{e.message}"
-
end
-
end
-
-
puts "\n" + "=" * 60
-
puts "Regenerated #{regenerated} previews"
-
puts "=" * 60